Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d53afaeb | |||
| 4263b3eb26 | |||
| d58f13e1ce | |||
| 81b3cbda65 | |||
| 0a99eed369 | |||
| 1ec9f2e592 | |||
| a401a318c5 | |||
| 5caad0df2d | |||
| 01e66ba122 | |||
| 923c1635b4 | |||
| c37bc5205b | |||
| 841263bce1 | |||
| e1f433804c | |||
| 2b5d7268ed | |||
| 2cc492bb88 | |||
| f6eb39eb73 | |||
| 2b26fe1efa | |||
| 1f18f151d9 | |||
| a27589418c | |||
| 57cbab916a | |||
| a7a8bfd24c | |||
| 0c964909e0 | |||
| 6511e4ec62 | |||
| 220309db12 | |||
| c3d88c0685 | |||
| e4ecd116a2 | |||
| 8d0477e0f7 | |||
| e7e9cb20d8 | |||
| ebfae9b891 | |||
| 3ab484ddc0 | |||
| 6bcef26446 | |||
| 240444eafd | |||
| d09c9d6633 | |||
| bc44cbc8b7 | |||
| e7060a5e42 | |||
| 4b8f59695b | |||
| da7b86d5c8 | |||
| efe21c79e9 | |||
| ee119fe437 | |||
| d97c403aab | |||
| c41114faf8 | |||
| c850f7603a | |||
| 5463e354fa | |||
| 783b7f4101 | |||
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef | |||
| d70a7c9403 | |||
| 63eaa739d4 | |||
| 9f8dc9082e | |||
| f1a90e120e | |||
| db5a7776cc | |||
| 6c2c094f64 | |||
| 609a35f251 | |||
| 97c39d5e15 | |||
| 4a82790734 | |||
| 80000793f4 | |||
| 39bea78a9c | |||
| 364fe4a286 | |||
| 38ea78b489 | |||
| 7b5abc6ad9 | |||
| ca55ff8ec5 | |||
| 0841d3e5c8 | |||
| 33d30f5e1f | |||
| 14e979837e | |||
| fa27b299e7 | |||
| 9e3d6f4ca1 | |||
| 26aabd6660 | |||
| 9d07f64154 | |||
| 0f17efe3b4 | |||
| 4999325210 | |||
| 1c6dfea5b5 | |||
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 | |||
| 631ea96f19 | |||
| dce32b96b7 | |||
| a0cd808456 | |||
| e3745d7504 | |||
| 8546748759 | |||
| cc2ece94c3 |
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
@@ -36,11 +40,8 @@
|
||||
"Bash(node -e ':*)",
|
||||
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
"Bash(gh pr *)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -327,6 +327,7 @@ graph TD
|
||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
||||
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
|
||||
| `lib/user-quota.js` | Server mode's spend quota per user (`user_quotas`, row 0 = the default; `PUT /api/users/quota-default`, `PATCH /api/users/:id {quota}`, `GET /api/me/quota`): `limit_usd` per local `day` or sliding `5h`, spend = the users page's `hourlyTokenRows` + `calculateCost` over owned sessions. Over it: `spawnRun` throws `EQUOTA` (429); a 60 s `sweep` writes each pane's `AGENTHUB_QUOTA_FILE` (the hook handler then exits 2 on `UserPromptSubmit`, from the file alone, fail-open), leaves the owner's omp keys out of the gateway's clients file and ends panes no hook can stop |
|
||||
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
||||
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
|
||||
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
|
||||
@@ -387,7 +388,11 @@ graph TD
|
||||
| `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; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). 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/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
|
||||
| `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/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`. History rewrites and remote writes are dispatched to `lib/git-rewrite.js` (below); a stopped rebase lets through only `ALLOWED_WHILE_REBASING`. No stash beyond shelves. (`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/git-rewrite.js` | History rewrites and remote writes, each allowed because its recovery is a button too: `rebase` (+ `rebase-continue`/`-skip`/`-abort`; `merge-commit`/`merge-abort` map onto an open rebase), `rebase-interactive` (todo + messages as 0600 files in the repo's git dir, `cp` as sequence editor, `true` as editor, squash = `fixup` + `exec git commit --amend -F <file>`), `reset` soft/mixed/keep/hard (hard: clean tree + branch name typed back), `undo-rewrite` (`reset --keep <before>` while HEAD is still `after`), `force-push` (`--force-with-lease` on the sha the plan showed, HEAD only, protected names refused unless `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`), `remote-branch-delete` (never the default or a protected branch), `remote-add`/`-rename`/`-remove` (URL through `validateRemoteUrl`). Every one: repo root (`ESUBDIR`), no open operation (`EMERGING`), the planned HEAD (`ESTALE`), pushed commits `EPUSHED` unless `force: true`. Plus the reads `rewritePlan` and `listRemotes` (passwords stripped) |
|
||||
| `lib/git-conflicts.js` | The 3-way merge editor's read (`readConflict`: stages :1/:2/:3 by blob id for a path `ls-files -u` lists, the working file and its sha) and save (`conflict-save`: `lane-file-write.js` then `git add`, `EMARKERS` before writing), and the read-only reflog (`readReflog`) |
|
||||
| `lib/git-history.js` | The History tab's READS (via `routes/lane-git-history.js`, plus `/git/log`): the commit graph paged with `skip`, `all=1` and literal search (`q`, a SHA prefix, `author`, `path`) and typed ref badges; one commit's details; its `format-patch` (a secret file left out); a file's `--follow` history; blame at a revision; a tree at a revision; two refs compared (left/right commits, `A...B` or `A B` files, one file's hunks); the plan of a multi-commit pick/revert; a paused pick's sequencer state. Every revision is `SAFE_REV`-checked and resolved to a sha first, every path `lanePath`/`isSecretOnDisk`, walks time out at 20 s, outputs are capped |
|
||||
| `lib/git-pick.js` | The multi-commit `cherry-pick`/`revert` (`shas`, one git sequence in `pickPlan`'s order), the paused one's `pick-continue`/`pick-skip`/`pick-abort` (the last two `confirm:true`) and `undo-pick` (`reset --soft <from>` only while HEAD is the `to` the pick made and none of it is on a remote). Dispatched from `git-ops.js`'s `runGitAction`; same clean-tree, repo-root and `EMERGING` guards |
|
||||
| `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 |
|
||||
|
||||
@@ -2557,7 +2562,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
|
||||
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
|
||||
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
|
||||
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
||||
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
|
||||
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+67
-69
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
|
||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// warning, not a failure: it costs the run console and `workspace shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available, upgrade } = await get("/api/run/tmux");
|
||||
@@ -1693,7 +1693,7 @@ function cmdOpen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
|
||||
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
|
||||
*/
|
||||
async function cmdLanesAdd(args) {
|
||||
const flag = (name) => {
|
||||
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
|
||||
if (base) body.base = base;
|
||||
if (slug) body.slug = slug;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
||||
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lane = provisioning;
|
||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
||||
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
|
||||
}
|
||||
if (lane.status === "provisioning") {
|
||||
console.error(
|
||||
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
|
||||
const body = { cwd, title };
|
||||
if (pipeline) body.pipeline = pipeline;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane } = await post("/api/lanes", body);
|
||||
const { lane } = await post("/api/workspaces", body);
|
||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* (owning session died) and cap oversized hook logs, across every lane on
|
||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||
* action, no HTTP round-trip.
|
||||
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||
* talk to the dashboard server at all.
|
||||
*/
|
||||
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
|
||||
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
|
||||
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
||||
* resolution (unlike every other `workspace` subcommand) — this is meant to run
|
||||
* against a bare repo right after `profile init`.
|
||||
*/
|
||||
async function cmdLanesProfileCheck(args) {
|
||||
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
|
||||
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
|
||||
async function cmdLanesLifecycle(action, args) {
|
||||
const id = args.find((arg) => !arg.startsWith("--"));
|
||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
||||
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
|
||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
const body = { confirm: true, expect };
|
||||
if (args.includes("--force")) body.force = true;
|
||||
if (keepDb) body.keepDb = true;
|
||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
const error = result.data?.error || {};
|
||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* whether the driving session is still breathing.
|
||||
*/
|
||||
/**
|
||||
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
|
||||
}
|
||||
|
||||
async function cmdLanes() {
|
||||
const { lanes, counts } = await get("/api/lanes");
|
||||
const { lanes, counts } = await get("/api/workspaces");
|
||||
if (!lanes.length) {
|
||||
console.log(
|
||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
|
||||
*
|
||||
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||
* Scanning the whole argv for a number would swallow flag values —
|
||||
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
|
||||
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
|
||||
*
|
||||
* @param {string[]} args - Arguments after the subcommand.
|
||||
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
|
||||
return i > -1 ? args[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const explicit = flagValue("lane") || flagValue("workspace");
|
||||
const explicit = flagValue("workspace");
|
||||
if (explicit) return { laneId: explicit, rest: args };
|
||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||
|
||||
const tool = flagValue("tool") || toolFromEnv();
|
||||
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||
const { lanes } = await get("/api/lanes");
|
||||
const { lanes } = await get("/api/workspaces");
|
||||
|
||||
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
||||
// against the live lane list AND the directory: a pane outlives the lane it
|
||||
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||
* lane id.
|
||||
*
|
||||
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const { laneId, rest: laneArgs } = resolved;
|
||||
|
||||
if (sub === "runtime") {
|
||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
||||
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const body = {};
|
||||
if (laneArgs.includes("--no-build")) body.build = false;
|
||||
if (laneArgs.includes("--qc")) body.qc = true;
|
||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
||||
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
|
||||
if (runtime.healthy) {
|
||||
printRuntime(runtime);
|
||||
return;
|
||||
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
|
||||
if (sub === "down") {
|
||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(
|
||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
const i = laneArgs.indexOf("--tail");
|
||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
if (!log.available) {
|
||||
console.log("no logs for this workspace yet.");
|
||||
return;
|
||||
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
return;
|
||||
}
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
{ args: laneArgs.slice(1) },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
: "merge";
|
||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/sync-base`,
|
||||
`/api/workspaces/${laneId}/sync-base`,
|
||||
{ mode, branch },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
|
||||
|
||||
const resolved = await resolveLaneArg(argsAfterName);
|
||||
if (!resolved) return null;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||
}
|
||||
|
||||
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
|
||||
async function cmdFeatureList(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
||||
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||
if (!features.length) {
|
||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||
return;
|
||||
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
|
||||
};
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
||||
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
|
||||
slug,
|
||||
title: flag("title"),
|
||||
});
|
||||
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
|
||||
}
|
||||
|
||||
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||
async function cmdLanesProofLink(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
||||
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
|
||||
console.log(
|
||||
linked
|
||||
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||
* profiles. Never run automatically; a session calls it explicitly. */
|
||||
async function cmdLanesMcpSync(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes integration <name> [<id>]` — check whether a named
|
||||
/** `agenthub workspace integration <name> [<id>]` — check whether a named
|
||||
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||
async function cmdLanesIntegration(args) {
|
||||
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||
if (!resolved) return;
|
||||
const { enabled } = await get(
|
||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
);
|
||||
console.log(enabled ? "on" : "off");
|
||||
process.exitCode = enabled ? 0 : 1;
|
||||
}
|
||||
|
||||
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
||||
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
|
||||
const resolved = await resolveLaneArg(args.slice(2));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* lane's pipeline instead of the whole thing.
|
||||
*
|
||||
* Every node already carries the one command that drives it (`command`, added
|
||||
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
|
||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||
*
|
||||
* - a slash command starts `claude` with it as the prompt;
|
||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
||||
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
|
||||
*
|
||||
* With no node id it lists the nodes and their commands, which is also the
|
||||
* error path's answer. `--print` shows the argv and runs nothing.
|
||||
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
|
||||
const { flags, positional } = parseArgs(args);
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
||||
// the template does. Merge, so the listing can still mark the current node.
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
||||
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||
...n,
|
||||
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
||||
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub workspace add --pipeline` could only set
|
||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||
* was stuck on `default` with no way back to a 16-node template.
|
||||
*/
|
||||
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
|
||||
if (!resolved) return;
|
||||
|
||||
if (!target) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||
console.log(
|
||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
|
||||
async function cmdLanesShell(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const sessionName = `agenthub-lane-${lane.id}`;
|
||||
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||
stdio: "inherit",
|
||||
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
|
||||
if (!resolved) return;
|
||||
const laneId = resolved.laneId;
|
||||
|
||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
||||
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||
stage,
|
||||
status: flag("status"),
|
||||
evidence: flag("evidence"),
|
||||
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
|
||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
|
||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||
@@ -3512,8 +3512,8 @@ function replHelp() {
|
||||
/** Detailed help for one command (or command prefix): every catalog entry
|
||||
* whose invocation starts with the token. Returns false if none matched. */
|
||||
function commandHelp(token) {
|
||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
||||
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||
const canon = (words) => {
|
||||
const [head, ...tail] = words.split(" ");
|
||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
|
||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||
// decides whether to fall back to an offline reader or report the refusal.
|
||||
|
||||
/** Command names that are another command under the user-facing word.
|
||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
||||
* release after the first one that shipped these aliases. */
|
||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
||||
/** Command names that are another command under the user-facing word. */
|
||||
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||
const canonicalArgv = (argv) =>
|
||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||
|
||||
/** One stderr line when an old name is typed, so people and agents move over
|
||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
||||
function warnOldNames(argv) {
|
||||
const old = [];
|
||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
||||
if (old.length) {
|
||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
||||
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||
* — never "Unknown command" or help, so a script written before says why. */
|
||||
function refuseOldNames(argv) {
|
||||
if (argv[0] === "lanes") {
|
||||
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||
process.exit(1);
|
||||
}
|
||||
if (argv.includes("--lane")) {
|
||||
console.error(c.red("✖ renamed: use --workspace <id>"));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
|
||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||
}
|
||||
set.add("help");
|
||||
// The old names stay typeable (and completable) for scripts written before.
|
||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||
return [...set];
|
||||
})();
|
||||
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
* points, not here, to avoid recursive REPLs. */
|
||||
async function runCommand(argv) {
|
||||
warnOldNames(argv);
|
||||
refuseOldNames(argv);
|
||||
const [cmd, ...rest] = canonicalArgv(argv);
|
||||
const { flags, positional } = parseArgs(rest);
|
||||
switch (cmd) {
|
||||
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
|
||||
return cmdReinstallHooks();
|
||||
case "update-check":
|
||||
return cmdUpdateCheck();
|
||||
case "lanes":
|
||||
case "workspace":
|
||||
if (rest[0] === "add") {
|
||||
return cmdLanesAdd(rest.slice(1));
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
||||
|
||||
The client is a single-page application (SPA) built with modern web technologies:
|
||||
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **React 19** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
@@ -1215,7 +1215,7 @@ graph TB
|
||||
|
||||
The client is a production-ready React application with:
|
||||
|
||||
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
|
||||
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
|
||||
- ⚡ **Real-time** - WebSocket integration for live updates
|
||||
- 🧪 **Tested** - Vitest + React Testing Library
|
||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||
|
||||
Generated
+427
-139
@@ -15,18 +15,18 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
@@ -34,17 +34,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
@@ -450,10 +450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||
"license": "MIT",
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
|
||||
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -478,10 +477,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"version": "6.43.15",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1661,7 +1659,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
@@ -2108,30 +2105,21 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
@@ -2140,6 +2128,326 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||
@@ -2282,59 +2590,38 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-canvas": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-clipboard": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
||||
"license": "MIT",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
|
||||
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
@@ -3706,9 +3993,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.5.11",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -3723,7 +4010,6 @@
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
@@ -3734,12 +4020,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
|
||||
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
"@babel/runtime": "^7.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
@@ -3924,6 +4209,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
@@ -4022,18 +4308,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -4052,12 +4326,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.474.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
|
||||
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
|
||||
"license": "ISC",
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
@@ -5378,35 +5657,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
||||
"license": "MIT",
|
||||
"version": "17.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
@@ -5755,13 +6027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -5774,9 +6042,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
|
||||
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
@@ -6183,17 +6451,37 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
|
||||
+13
-13
@@ -18,18 +18,18 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
@@ -37,17 +37,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* @file BackgroundJobsDock.tsx
|
||||
* @description The one bottom-right stack of background work, rendered once
|
||||
* by `Layout`: a chip per background clone (`lib/cloneJobs.ts`, kept by the
|
||||
* server, so it is back after F5 or a server restart) and per File Explorer
|
||||
* upload (`lib/uploadJobs.ts`). A clone chip shows its queue place, then
|
||||
* git's progress, with **Stop** (click twice) while it runs; once finished,
|
||||
* **Open workspace** (done), the reason plus **Retry** (failed, interrupted,
|
||||
* stopped) — an inline login form when the host wants one, a link to
|
||||
* Settings → Git when it refused the SSH key — and × to dismiss. An upload
|
||||
* chip shows its progress with **Stop**; one a reload cut short says how far
|
||||
* it got. Several chips collapse to one small pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
|
||||
import type { CloneJob } from "../lib/api";
|
||||
import {
|
||||
cancelCloneJob,
|
||||
cloneJobError,
|
||||
dismissCloneJob,
|
||||
ensureCloneJobs,
|
||||
isActiveJob,
|
||||
retryCloneJob,
|
||||
useCloneJobs,
|
||||
} from "../lib/cloneJobs";
|
||||
import { dismissUpload, stopUpload, useUploadJobs, type UploadJob } from "../lib/uploadJobs";
|
||||
import { useArmed } from "../hooks/useArmed";
|
||||
import { CloneProgress } from "./lanes/CloneProgress";
|
||||
|
||||
const chipClass =
|
||||
"pointer-events-auto w-full space-y-1.5 rounded-lg border border-border bg-surface-1 p-2.5 text-xs shadow-xl shadow-black/40";
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
danger,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
danger?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
className={`-m-0.5 shrink-0 rounded p-1 hover:bg-surface-2 ${
|
||||
danger ? "text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CloneJobChip({ job }: { job: CloneJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
const [login, setLogin] = useState({ username: "", token: "", remember: true });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const active = isActiveJob(job);
|
||||
const code = job.error?.code;
|
||||
const laneId = job.kind === "workspace" ? (job.result?.laneId ?? null) : job.laneId;
|
||||
const title =
|
||||
job.state === "done"
|
||||
? t("cloneJobs.done", { name: job.name })
|
||||
: job.state === "cancelled"
|
||||
? t("cloneJobs.cancelledTitle", { name: job.name })
|
||||
: active
|
||||
? t("cloneJobs.cloning", { name: job.name })
|
||||
: t("cloneJobs.failedTitle", { name: job.name });
|
||||
|
||||
const retry = async (withLogin: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await retryCloneJob(
|
||||
job.id,
|
||||
withLogin
|
||||
? { username: login.username.trim(), password: login.token, remember: login.remember }
|
||||
: undefined
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
void cancelCloneJob(job.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={chipClass} data-testid="clone-job" data-state={job.state} title={job.target}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">{title}</span>
|
||||
{active ? (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("cloneJobs.stopConfirm") : t("cloneJobs.stop")}
|
||||
onClick={stop}
|
||||
danger={isArmed("stop")}
|
||||
testId="clone-job-stop"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
label={t("cloneJobs.dismiss")}
|
||||
onClick={() => void dismissCloneJob(job.id)}
|
||||
testId="clone-job-dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{active && isArmed("stop") && (
|
||||
<p className="text-[11px] text-status-danger">{t("cloneJobs.stopConfirm")}</p>
|
||||
)}
|
||||
{active && <CloneProgress view={job} />}
|
||||
{job.state === "done" && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{job.result?.path || job.target}
|
||||
</span>
|
||||
{laneId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
onClick={() => {
|
||||
navigate(`/workspace?lane=${laneId}`);
|
||||
void dismissCloneJob(job.id);
|
||||
}}
|
||||
>
|
||||
{job.kind === "workspace" ? t("cloneJobs.openWorkspace") : t("cloneJobs.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!active && job.state !== "done" && (
|
||||
<>
|
||||
<p
|
||||
role={job.state === "cancelled" ? undefined : "alert"}
|
||||
className={`text-[11px] [overflow-wrap:anywhere] ${
|
||||
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{cloneJobError(job)}{" "}
|
||||
{code === "ESSHAUTH" && (
|
||||
// A router link: a plain <a href> reloaded the whole app.
|
||||
<Link to="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
{code === "ECREDENTIALS" && job.state === "failed" ? (
|
||||
<form
|
||||
data-testid="clone-job-login"
|
||||
className="space-y-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void retry(true);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !login.username.trim() || !login.token}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("cloneJobs.retryLogin")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
code !== "ESSHAUTH" && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void retry(false)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("cloneJobs.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadChip({ job }: { job: UploadJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
if (job.state === "interrupted") {
|
||||
return (
|
||||
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{t("uploads.interrupted", {
|
||||
total: job.total,
|
||||
folder: job.folder,
|
||||
done: job.done,
|
||||
})}
|
||||
</p>
|
||||
<IconButton label={t("cloneJobs.dismiss")} onClick={() => dismissUpload(job.id)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const stopping = job.state === "stopping";
|
||||
return (
|
||||
<div className={chipClass} data-testid="fx-progress" data-state={job.state}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">
|
||||
{stopping
|
||||
? t("uploads.stopping")
|
||||
: t("fx.uploading", { done: Math.min(job.done + 1, job.total), total: job.total })}
|
||||
</span>
|
||||
{!stopping && (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("uploads.stopConfirm") : t("uploads.stop")}
|
||||
danger={isArmed("stop")}
|
||||
testId="upload-job-stop"
|
||||
onClick={() => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
stopUpload(job.id);
|
||||
}}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{isArmed("stop") && !stopping && (
|
||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
</p>
|
||||
<div className="h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{ width: `${Math.min(100, (job.sent / Math.max(1, job.bytes)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundJobsDock() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const jobs = useCloneJobs();
|
||||
const uploads = useUploadJobs();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => ensureCloneJobs(), []);
|
||||
|
||||
const count = jobs.length + uploads.length;
|
||||
// Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to
|
||||
// sit on the same corner and cover a chip's × / Retry / Stop.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const el = dockRef.current;
|
||||
if (!count || !el) {
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
return;
|
||||
}
|
||||
const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`);
|
||||
set();
|
||||
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
|
||||
ro?.observe(el);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
};
|
||||
}, [count, collapsed]);
|
||||
if (!count) return null;
|
||||
const running =
|
||||
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
role="region"
|
||||
aria-label={t("jobs.dock")}
|
||||
data-testid="jobs-dock"
|
||||
className="pointer-events-none fixed bottom-10 right-4 z-[60] flex max-h-[70vh] w-[min(20rem,calc(100vw-2rem))] flex-col items-end gap-2 overflow-y-auto"
|
||||
>
|
||||
{count > 1 && collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-pill"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title={t("jobs.expand")}
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border bg-surface-1 px-3 py-1 text-xs text-fg-primary shadow-lg shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-fg-muted" />
|
||||
{t("jobs.count", { count })}
|
||||
{running > 0 && (
|
||||
<span className="text-fg-muted">· {t("jobs.running", { count: running })}</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{count > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-collapse"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border bg-surface-1 px-2 py-0.5 text-[11px] text-fg-secondary shadow hover:bg-surface-2"
|
||||
>
|
||||
{t("jobs.count", { count })}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{uploads.map((u) => (
|
||||
<UploadChip key={u.id} job={u} />
|
||||
))}
|
||||
{jobs.map((j) => (
|
||||
<CloneJobChip key={j.id} job={j} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file HtmlFrame.tsx
|
||||
* @description An HTML file rendered as a page, the one way every file view
|
||||
* shows one (a Chat file preview, the Changes panel's Preview, the File
|
||||
* Explorer's viewer): its relative CSS, scripts and images pulled in through
|
||||
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
|
||||
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
|
||||
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
|
||||
* file itself through the same `fetchFile`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||
|
||||
/** A page read by `fetchFile` is shown up to this size. A self-contained
|
||||
* page (a slide deck with its video and images inlined as base64) is easily
|
||||
* past 10 MB; the iframe renders far larger documents than this. */
|
||||
const HTML_MAX = 64 * 1024 * 1024;
|
||||
|
||||
export function HtmlFrame({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
title,
|
||||
className = "",
|
||||
testId = "html-frame",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
/** Reads the page's relative files; none = the page as it is. */
|
||||
fetchFile?: FetchFile | null;
|
||||
title: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [doc, setDoc] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// The page shown stays until its successor is ready (a version step, a
|
||||
// re-read): no flash of the spinner between two renders of one file.
|
||||
setFailed(false);
|
||||
(async () => {
|
||||
let text = html;
|
||||
if (text === undefined) {
|
||||
if (!fetchFile) throw new Error("nothing to read the page with");
|
||||
const b = await fetchFile(path);
|
||||
if (b.size > HTML_MAX) throw new Error("too big");
|
||||
text = await blobText(b);
|
||||
}
|
||||
const page = text;
|
||||
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
|
||||
})().then(
|
||||
(d) => live && setDoc(d),
|
||||
() => live && setFailed(true)
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [html, path, fetchFile]);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p className="break-all font-mono text-xs">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (doc === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
srcDoc={artifactDocument("html", doc, false) ?? ""}
|
||||
sandbox={frameSandbox("html")}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
|
||||
export function fetchVia(url: (relPath: string) => string): FetchFile {
|
||||
return async (rel) => {
|
||||
const r = await fetch(url(rel), { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.blob();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file HtmlViewerModal.tsx
|
||||
* @description An HTML file shown as the page in a modal over the whole
|
||||
* screen — the one viewer the File Explorer and the Changes panel's Preview
|
||||
* open (the Chat's file preview opens its own panel as a modal instead, for
|
||||
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
|
||||
* artifact's sandbox, its relative files read through the caller's guarded
|
||||
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
|
||||
* underneath (the explorer's Delete) never fires from inside it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { HtmlFrame } from "./HtmlFrame";
|
||||
import type { FetchFile } from "../lib/htmlInline";
|
||||
|
||||
export function HtmlViewerModal({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
onDownload,
|
||||
onClose,
|
||||
testId = "html-viewer",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from; also its title. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
fetchFile?: FetchFile | null;
|
||||
onDownload?: () => void;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Nothing reaches the view underneath — so Esc, which useDialog's
|
||||
// document listener would never see, closes here.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
data-testid={testId}
|
||||
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
|
||||
{path}
|
||||
</span>
|
||||
{onDownload && (
|
||||
<HeadButton label={t("fx.download")} onClick={onDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
)}
|
||||
<HeadButton label={t("fx.close")} onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
</div>
|
||||
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./BackgroundJobsDock` (background clones and uploads, app-wide)
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { BackgroundJobsDock } from "./BackgroundJobsDock";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<BackgroundJobsDock />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||
|
||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const rafRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
|
||||
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the session
|
||||
* header looks as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
|
||||
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
|
||||
* start nothing and their prompts are refused until it opens again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -30,9 +33,9 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -279,7 +282,7 @@ function QuotaChips() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
@@ -369,6 +372,8 @@ export function StatusBar({
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
// Server mode: this user's own spend quota (null: none, or local mode).
|
||||
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -414,6 +419,10 @@ export function StatusBar({
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
api.me
|
||||
.quota()
|
||||
.then((r) => live() && setMyQuota(r?.quota ?? null))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -524,6 +533,26 @@ export function StatusBar({
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
{myQuota && (
|
||||
<span
|
||||
data-testid="status-bar-user-quota"
|
||||
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
|
||||
spend: usd(myQuota.spend),
|
||||
limit: usd(myQuota.limit_usd),
|
||||
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
|
||||
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
|
||||
})}
|
||||
className={`tabular-nums ${
|
||||
myQuota.exceeded
|
||||
? "text-status-danger"
|
||||
: myQuota.pct >= 80
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
|
||||
</span>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,9 @@ function Item({ item }: { item: ToastItem }) {
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
||||
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
|
||||
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
|
||||
{item.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
@@ -44,8 +46,10 @@ export function Toaster() {
|
||||
useEffect(() => subscribeToasts(setItems), []);
|
||||
const errors = items.filter((i) => i.kind === "error");
|
||||
const rest = items.filter((i) => i.kind !== "error");
|
||||
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
|
||||
// to its height), so a toast never covers a chip's buttons.
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
||||
{rest.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
|
||||
@@ -260,9 +260,8 @@ export function UpdateNotifier() {
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (!dismissKey) return;
|
||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
||||
setClosedKey(dismissKey);
|
||||
}, [applying, restarting, dismissKey, t]);
|
||||
}, [dismissKey]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
|
||||
function renderCard(element: JSX.Element) {
|
||||
function renderCard(element: React.JSX.Element) {
|
||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
|
||||
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
|
||||
* announced once when the store sees it finish (toast + window event); a
|
||||
* fresh mount (F5) shows what the server kept — an interrupted clone with
|
||||
* Retry, a login failure with an inline login form that retries with it —
|
||||
* without toasting it again; × dismisses; Stop (click twice) cancels a
|
||||
* running clone, which then reads as stopped and can be retried; an older
|
||||
* server that cannot stop says so. An upload shows with Stop, and one a
|
||||
* reload cut short is shown as interrupted until dismissed. Several chips
|
||||
* collapse to one pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../i18n";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
clone: vi.fn(),
|
||||
cloneFolder: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
retryCloneJob: vi.fn(),
|
||||
dismissCloneJob: vi.fn(),
|
||||
cancelCloneJob: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api, type CloneJob } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
CLONE_JOB_EVENT,
|
||||
refreshCloneJobs,
|
||||
resetCloneJobsForTests,
|
||||
startClone,
|
||||
} from "../../lib/cloneJobs";
|
||||
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
|
||||
import { BackgroundJobsDock } from "../BackgroundJobsDock";
|
||||
|
||||
const base: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "running",
|
||||
url: "https://git.example/o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an",
|
||||
target: "/home/an/repo",
|
||||
laneId: null,
|
||||
position: null,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 40,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
|
||||
};
|
||||
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
|
||||
|
||||
const renderDock = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BackgroundJobsDock />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
reloadUploadsForTests();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
|
||||
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("cloneJobs store", () => {
|
||||
it("announces a background clone once, when it sees it finish", async () => {
|
||||
const onDone = vi.fn();
|
||||
const events: CloneJob[] = [];
|
||||
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
|
||||
window.addEventListener(CLONE_JOB_EVENT, listen);
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
|
||||
await startClone(
|
||||
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
|
||||
{ onDone }
|
||||
);
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
|
||||
await refreshCloneJobs();
|
||||
await refreshCloneJobs();
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
|
||||
expect(events).toEqual([done]);
|
||||
expect(onDone).toHaveBeenCalledWith(done, undefined);
|
||||
window.removeEventListener(CLONE_JOB_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackgroundJobsDock", () => {
|
||||
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("Cloning repo");
|
||||
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an interrupted clone after F5 and retries it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "interrupted",
|
||||
error: { code: "EINTERRUPTED", message: "server restarted" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
|
||||
job: job({ id: "j2", state: "queued", position: 1 }),
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(within(chip).getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and retries with it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
error: {
|
||||
code: "ECREDENTIALS",
|
||||
message: "login",
|
||||
auth: { protocol: "https", host: "git.example" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const form = await screen.findByTestId("clone-job-login");
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(form).getByLabelText("Username"), "ann");
|
||||
await user.type(within(form).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
|
||||
username: "ann",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a finished clone", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
|
||||
await user.click(within(chip).getByTestId("clone-job-dismiss"));
|
||||
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops a running clone on a second click, then lets it be retried", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
|
||||
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const stop = await screen.findByTestId("clone-job-stop");
|
||||
await user.click(stop);
|
||||
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
|
||||
const chip = await screen.findByText("You stopped this clone.");
|
||||
expect(chip).toBeInTheDocument();
|
||||
// The chip says it; no toast repeats it on top of the chip.
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the server cannot stop a clone yet", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
|
||||
Object.assign(new Error("HTTP 404"), { status: 404 })
|
||||
);
|
||||
renderDock();
|
||||
await user.click(await screen.findByTestId("clone-job-stop"));
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an upload with Stop, which aborts it", async () => {
|
||||
renderDock();
|
||||
let up!: ReturnType<typeof beginUpload>;
|
||||
act(() => {
|
||||
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
|
||||
up.update({ name: "a.txt", done: 1, sent: 150 });
|
||||
});
|
||||
const chip = await screen.findByTestId("fx-progress");
|
||||
expect(chip).toHaveTextContent("Uploading 2/3");
|
||||
expect(chip).toHaveTextContent("one/src · a.txt");
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(false);
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(true);
|
||||
act(() => up.end());
|
||||
expect(screen.queryByTestId("fx-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an upload a reload cut short until it is dismissed", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.uploads.inflight",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "up-old",
|
||||
folder: "one/src",
|
||||
name: "b.txt",
|
||||
done: 2,
|
||||
total: 5,
|
||||
sent: 10,
|
||||
bytes: 50,
|
||||
state: "running",
|
||||
beat: Date.now() - 60_000,
|
||||
},
|
||||
])
|
||||
);
|
||||
act(() => reloadUploadsForTests());
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("upload-job");
|
||||
expect(chip).toHaveTextContent(
|
||||
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
|
||||
);
|
||||
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByTestId("upload-job")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses several chips to one pill", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job(), job({ id: "j2", name: "other" })],
|
||||
});
|
||||
renderDock();
|
||||
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
|
||||
await user.click(screen.getByTestId("jobs-dock-collapse"));
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
|
||||
await user.click(screen.getByTestId("jobs-dock-pill"));
|
||||
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
|
||||
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
url: long,
|
||||
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
renderDock();
|
||||
const alert = await screen.findByRole("alert");
|
||||
// A URL with no space to break at must still wrap inside the chip.
|
||||
expect(alert.className).toContain("[overflow-wrap:anywhere]");
|
||||
// A router link, not a full page reload.
|
||||
const link = screen.getByRole("link", { name: /Settings/ });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
me: {
|
||||
// Server mode: this user's own spend quota, used up.
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
quota: {
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
spend: 12,
|
||||
pct: 120,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
@@ -151,7 +165,7 @@ import { api } from "../../lib/api";
|
||||
import { StatusBar } from "../StatusBar";
|
||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||
|
||||
const at = (url: string, ui: JSX.Element) =>
|
||||
const at = (url: string, ui: React.JSX.Element) =>
|
||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||
|
||||
describe("StatusBar", () => {
|
||||
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("shows the user's own spend quota, red once used up", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-user-quota");
|
||||
expect(chip.textContent).toBe("Quota 120%");
|
||||
expect(chip.className).toContain("text-status-danger");
|
||||
expect(chip.getAttribute("title")).toMatch(
|
||||
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
|
||||
);
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Tests for the sidebar's workspace list: one flat row per
|
||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||
* a live session opening its pane and an ended one its transcript, the
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive
|
||||
* (a live one's pane stopped first),
|
||||
* selection
|
||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||
* search, the row menu's items by kind and tool, the status dot, and the
|
||||
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
|
||||
await user.type(input, "Better name{Enter}");
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||
// A live session's Archive stops its pane first — never a hidden pane left running.
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file MiniChart.tsx
|
||||
* @description The Administration page's small single-series charts: a bar
|
||||
* chart (a user's daily spend) and a line chart (System Health's CPU and
|
||||
* memory history), plain SVG in the app's theme tokens. One series each — the
|
||||
* card's title names it, so there is no legend — thin marks, rounded bar ends
|
||||
* on the baseline, a recessive grid with a max label, and a hover tooltip
|
||||
* (crosshair for the line, per bar for the bars) whose text is the caller's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
|
||||
export type ChartPoint = { x: string; y: number };
|
||||
|
||||
const H = 120;
|
||||
const PAD_TOP = 14;
|
||||
const PAD_BOTTOM = 18;
|
||||
|
||||
/**
|
||||
* @param props.points - In x order; `x` is the axis/tooltip label.
|
||||
* @param props.kind - `bar` or `line`.
|
||||
* @param props.format - Formats a value for the max label and the tooltip.
|
||||
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
|
||||
* @param props.label - The chart's accessible name.
|
||||
*/
|
||||
export function MiniChart({
|
||||
points,
|
||||
kind,
|
||||
format,
|
||||
max,
|
||||
label,
|
||||
emptyText,
|
||||
}: {
|
||||
points: ChartPoint[];
|
||||
kind: "bar" | "line";
|
||||
format: (v: number) => string;
|
||||
max?: number;
|
||||
label: string;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (points.length === 0)
|
||||
return (
|
||||
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
|
||||
{emptyText ?? "—"}
|
||||
</div>
|
||||
);
|
||||
|
||||
const W = 600;
|
||||
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
|
||||
const plotH = H - PAD_TOP - PAD_BOTTOM;
|
||||
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
|
||||
const step = W / points.length;
|
||||
const xOf = (i: number) =>
|
||||
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
|
||||
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r || r.width === 0) return;
|
||||
const fx = ((e.clientX - r.left) / r.width) * W;
|
||||
const i =
|
||||
kind === "bar"
|
||||
? Math.floor(fx / step)
|
||||
: Math.round((fx / W) * Math.max(1, points.length - 1));
|
||||
setHover(Math.max(0, Math.min(points.length - 1, i)));
|
||||
};
|
||||
|
||||
const h = hover != null ? points[hover] : null;
|
||||
const barW = Math.max(2, Math.min(24, step - 2));
|
||||
const line = points
|
||||
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="block h-[120px] w-full"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={0}
|
||||
x2={W}
|
||||
y1={yOf(top * f)}
|
||||
y2={yOf(top * f)}
|
||||
className="stroke-border"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeDasharray={f === 0 ? undefined : "3 3"}
|
||||
/>
|
||||
))}
|
||||
{kind === "bar" ? (
|
||||
points.map((p, i) => {
|
||||
const y = yOf(p.y);
|
||||
const bh = Math.max(0, PAD_TOP + plotH - y);
|
||||
const r = Math.min(4, barW / 2, bh);
|
||||
const x = xOf(i) - barW / 2;
|
||||
const base = PAD_TOP + plotH;
|
||||
// Rounded top only; the base sits flat on the axis.
|
||||
const d =
|
||||
bh <= 0
|
||||
? ""
|
||||
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
|
||||
return (
|
||||
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
|
||||
className="fill-accent-solid"
|
||||
opacity={0.12}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
className="stroke-accent-solid"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{hover != null && (
|
||||
<line
|
||||
x1={xOf(hover)}
|
||||
x2={xOf(hover)}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + plotH}
|
||||
className="stroke-fg-muted"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
|
||||
{format(top)}
|
||||
</span>
|
||||
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
|
||||
<span>{points[0]!.x}</span>
|
||||
<span>{points[points.length - 1]!.x}</span>
|
||||
</div>
|
||||
{h && hover != null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
|
||||
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
|
||||
role="status"
|
||||
>
|
||||
<div className="text-fg-muted">{h.x}</div>
|
||||
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -865,7 +865,7 @@ function MessageListView({
|
||||
className={
|
||||
compact
|
||||
? "space-y-3 py-1 pr-2"
|
||||
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
|
||||
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
|
||||
}
|
||||
>
|
||||
{items.map((item) => {
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* @file HostHealth.tsx
|
||||
* @description The machine AgentHub runs on, as part of the Dashboard's
|
||||
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
|
||||
* signed-in user sees all of it). In the tab's own card style:
|
||||
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
|
||||
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
|
||||
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
|
||||
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
||||
* server started); then CPU (every thread as a compact cell, temperatures,
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for (history, CPU threads and
|
||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
* Read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
ListTree,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostHistory,
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
|
||||
export type HostState = {
|
||||
stats: HostStats | null;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
if (!pollMs) return;
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void reload();
|
||||
}, pollMs);
|
||||
return () => clearInterval(id);
|
||||
}, [enabled, pollMs, reload]);
|
||||
return { stats, error, reload };
|
||||
}
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
export function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The System Health tab's own bar: h-2, green / amber / red by level. */
|
||||
function HealthBar({
|
||||
label,
|
||||
value,
|
||||
tip,
|
||||
warn = 70,
|
||||
bad = 90,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
tip?: string;
|
||||
warn?: number;
|
||||
bad?: number;
|
||||
}) {
|
||||
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||||
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
|
||||
return (
|
||||
<Tip block raw={tip}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-fg-muted">{label}</span>
|
||||
<span className="text-fg-secondary font-mono">
|
||||
{value == null ? "—" : `${Math.round(v)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="w-full bg-surface-3 rounded-full h-2"
|
||||
>
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
|
||||
style={{ width: `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
|
||||
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-fg-muted">{n}</span>
|
||||
<span className={tone}>{Math.round(v)}%</span>
|
||||
</div>
|
||||
<Meter value={v} label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHead({
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
right,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
tone: string;
|
||||
title: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
|
||||
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
|
||||
export type HeapInfo = {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapLimit?: number;
|
||||
external: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tab's first card: the machine and the AgentHub process on it.
|
||||
* `heap` comes from the tab's settings read.
|
||||
*/
|
||||
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const s = host.stats;
|
||||
const mem = s?.memory;
|
||||
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
|
||||
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
|
||||
return (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
|
||||
<CardHead
|
||||
icon={Server}
|
||||
tone="text-status-success"
|
||||
title={t("host.title")}
|
||||
right={
|
||||
s && (
|
||||
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
|
||||
{s.hostname}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!s ? (
|
||||
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2.5">
|
||||
<Row label={t("host.os")}>
|
||||
{s.os.distro ?? s.os.platform} · {s.os.release}
|
||||
</Row>
|
||||
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
|
||||
<Row label={t("host.cpuModel")}>
|
||||
<Tip raw={s.cpu.model ?? undefined}>
|
||||
<span>
|
||||
{s.cpu.physicalCores
|
||||
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
|
||||
: ""}
|
||||
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
{s.os.platform !== "win32" && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||||
{t("host.load")}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{load.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{s.process && (
|
||||
<Row label={t("host.agenthub")}>
|
||||
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
||||
<span>
|
||||
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{s.usage && (
|
||||
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
|
||||
)}
|
||||
<HealthBar
|
||||
label={t("host.memory")}
|
||||
value={mem ? pct(mem.used, mem.total) : null}
|
||||
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
/>
|
||||
{mem && mem.swapTotal > 0 && (
|
||||
<HealthBar
|
||||
label={t("host.swap")}
|
||||
value={pct(mem.swapUsed, mem.swapTotal)}
|
||||
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
|
||||
/>
|
||||
)}
|
||||
{heap && (
|
||||
<HealthBar
|
||||
label={t("health.heap")}
|
||||
value={heapPct}
|
||||
warn={60}
|
||||
bad={85}
|
||||
tip={t("health.heapTip", {
|
||||
pct: (heapPct ?? 0).toFixed(1),
|
||||
used: formatBytes(heap.heapUsed),
|
||||
limit: formatBytes(heapMax),
|
||||
allocated: formatBytes(heap.heapTotal),
|
||||
external: formatBytes(heap.external),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab's machine group: host + history, CPU / disks / processes, users. */
|
||||
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { stats } = host;
|
||||
const [range, setRange] = useState<HostHistoryRange>("1h");
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.history(range)
|
||||
.then(setHistory)
|
||||
.catch(() => setHistory(null)),
|
||||
[range]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void loadHistory();
|
||||
}, HISTORY_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [loadHistory]);
|
||||
|
||||
// Same tree as the full render below, so the card is not remounted once stats arrive.
|
||||
if (!stats)
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const groups = stats.usage?.groups ?? [];
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("host.hostGroup");
|
||||
if (g.kind === "system") return t("host.systemGroup");
|
||||
return g.displayName || g.username || "";
|
||||
};
|
||||
const groupByKey = new Map(groups.map((g) => [g.key, g]));
|
||||
const ownerOf = (key: string) => {
|
||||
const g = groupByKey.get(key);
|
||||
if (g) return groupName(g);
|
||||
if (key === "host") return t("host.hostGroup");
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const histPoints = (key: "cpu" | "memory") =>
|
||||
(history?.points ?? [])
|
||||
.filter((p) => p[key] != null)
|
||||
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
|
||||
const perCore = stats.cpu.perCore ?? [];
|
||||
const network = stats.network ?? [];
|
||||
const temps = stats.temperatures ?? [];
|
||||
const topProcs = stats.topProcesses ?? [];
|
||||
const others = groups.filter((g) => g.kind !== "user");
|
||||
const people = groups.filter((g) => g.kind === "user");
|
||||
// History, per-thread CPU and processes are read from /proc: on Windows and
|
||||
// macOS their cards would only ever be empty, so they are left out.
|
||||
const linux = stats.os.platform === "linux";
|
||||
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
|
||||
const showProcs = !!stats.usage || others.length > 0;
|
||||
|
||||
// A whole disk's I/O goes on the first mounted file system that lives on it
|
||||
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
||||
// mounted gets a row of its own.
|
||||
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
|
||||
const diskRows = stats.disks.map((d) => {
|
||||
const base = d.device.split("/").pop() ?? "";
|
||||
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
|
||||
const io = key ? ioLeft.get(key) : undefined;
|
||||
if (key) ioLeft.delete(key);
|
||||
return { disk: d, io };
|
||||
});
|
||||
const loneIo = [...ioLeft.values()];
|
||||
|
||||
const rate = (down?: number, up?: number) => (
|
||||
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowDown className="w-2.5 h-2.5" />
|
||||
{down == null ? "—" : perSec(down)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowUp className="w-2.5 h-2.5" />
|
||||
{up == null ? "—" : perSec(up)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
|
||||
{/* CPU and memory over time. */}
|
||||
{linux && (
|
||||
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||
<CardHead
|
||||
icon={Activity}
|
||||
tone="text-accent"
|
||||
title={t("host.historyTitle")}
|
||||
right={
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("host.range")}
|
||||
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
: "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{t(`host.range${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("cpu")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyCpu")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("memory")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyMemory")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{history?.startedAt && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("host.historyHelp", {
|
||||
s: history.intervalMs / 1000,
|
||||
at: formatDateTime(history.startedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
|
||||
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||
{showCores && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||
<CardHead
|
||||
icon={Cpu}
|
||||
tone="text-syntax-keyword"
|
||||
title={t("host.cpu")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{perCore.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||
{perCore.map((v, i) => (
|
||||
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{temps.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-temperatures"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||
{t("host.temperatures")}
|
||||
</div>
|
||||
{temps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
s.celsius >= 90
|
||||
? "bg-status-danger/20 text-status-danger"
|
||||
: s.celsius >= 75
|
||||
? "bg-status-warning/20 text-status-warning"
|
||||
: "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{s.celsius.toFixed(0)} °C
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.gpus.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{stats.gpus.map((g, i) => (
|
||||
<HealthBar
|
||||
key={i}
|
||||
label={g.name}
|
||||
value={g.utilization}
|
||||
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disks (with their I/O) and network interfaces. */}
|
||||
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
|
||||
<CardHead
|
||||
icon={HardDrive}
|
||||
tone="text-syntax-builtin"
|
||||
title={t("host.storageNetwork")}
|
||||
/>
|
||||
<div className="space-y-3" data-testid="server-disk-io">
|
||||
{diskRows.map(({ disk: d, io }) => (
|
||||
<div key={d.device} className="space-y-1">
|
||||
<HealthBar
|
||||
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
|
||||
value={pct(d.used, d.total)}
|
||||
warn={75}
|
||||
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
|
||||
/>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(d.free)} {t("host.free")}
|
||||
</span>
|
||||
{io && rate(io.read, io.write)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loneIo.map((io) => (
|
||||
<div key={io.name} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
|
||||
<span className="ml-auto">{rate(io.read, io.write)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{network.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-network"
|
||||
>
|
||||
{network.map((n) => (
|
||||
<div key={n.name} className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<Network className="w-3 h-3 text-fg-muted" />
|
||||
<span className="font-mono">{n.name}</span>
|
||||
</span>
|
||||
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The busiest processes, and what belongs to no user. */}
|
||||
{showProcs && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||
<CardHead
|
||||
icon={ListTree}
|
||||
tone="text-syntax-function"
|
||||
title={t("host.topProcesses")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!stats.usage ? (
|
||||
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcs.map((p) => (
|
||||
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(p.memory)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{fmtPct(p.cpu)}
|
||||
</span>
|
||||
</div>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||
data-testid="server-usage-table"
|
||||
>
|
||||
{others.map((g) => (
|
||||
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||
{groupName(g)}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary">
|
||||
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Each user's live share of the machine (server mode). */}
|
||||
{people.length > 0 && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
|
||||
<CardHead
|
||||
icon={Users}
|
||||
tone="text-syntax-string"
|
||||
title={t("host.usersTitle")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.usersCount", { n: people.length })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{people.map((g) => {
|
||||
const name = groupName(g);
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||
{g.top.map((p) => (
|
||||
<li
|
||||
key={p.pid}
|
||||
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||
>
|
||||
<span className="truncate text-fg-secondary">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-fg-muted">
|
||||
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file HostHealth.test.tsx
|
||||
* @description The machine on the Dashboard's System Health tab
|
||||
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
|
||||
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
|
||||
* rows under it show every CPU thread, temperatures, each disk with its I/O
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { HostStats } from "../../../lib/api";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const stats: HostStats = {
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
||||
uptime: 90000,
|
||||
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
||||
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
||||
disks: [
|
||||
{
|
||||
device: "/dev/sdb2",
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
kind: "SSD",
|
||||
total: 200 * GB,
|
||||
used: 50 * GB,
|
||||
free: 150 * GB,
|
||||
},
|
||||
{
|
||||
device: "/dev/sda1",
|
||||
mount: "/data",
|
||||
type: "ext4",
|
||||
kind: "HDD",
|
||||
total: 500 * GB,
|
||||
used: 0,
|
||||
free: 500 * GB,
|
||||
},
|
||||
],
|
||||
homesMount: "/",
|
||||
gpus: [],
|
||||
usage: {
|
||||
cpu: 40,
|
||||
sampleMs: 1000,
|
||||
groups: [
|
||||
{
|
||||
key: "user:2",
|
||||
kind: "user",
|
||||
userId: 2,
|
||||
username: "mia",
|
||||
cpu: 25,
|
||||
memory: 2 * GB,
|
||||
memoryPercent: 12.5,
|
||||
processes: 3,
|
||||
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
||||
},
|
||||
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
});
|
||||
|
||||
import { HostSections, useHostStats } from "../HostHealth";
|
||||
|
||||
function Tab() {
|
||||
const host = useHostStats();
|
||||
return (
|
||||
<HostSections
|
||||
host={host}
|
||||
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const full: HostStats = {
|
||||
...stats,
|
||||
usage: {
|
||||
...stats.usage!,
|
||||
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
|
||||
},
|
||||
bootTime: "2026-10-14T00:00:00Z",
|
||||
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
|
||||
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
|
||||
diskIo: [{ name: "sda", read: 4096, write: 0 }],
|
||||
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
|
||||
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
|
||||
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: true,
|
||||
startedAt: "2026-10-15T00:00:00Z",
|
||||
points: [
|
||||
{
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
cpu: 30,
|
||||
memory: 50,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
{
|
||||
at: "2026-10-15T00:00:30Z",
|
||||
cpu: 40,
|
||||
memory: 52,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostHealth", () => {
|
||||
it("names the machine and the AgentHub process, with bars", async () => {
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText("box")).toBeTruthy();
|
||||
expect(card.textContent).toContain("Ubuntu 26.04");
|
||||
expect(card.textContent).toContain("1.0 GB");
|
||||
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"40"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
|
||||
"50"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
|
||||
render(<Tab />);
|
||||
expect(await screen.findByTestId("server-history")).toBeTruthy();
|
||||
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
|
||||
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
|
||||
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
|
||||
const disks = screen.getByTestId("server-disk-io").textContent;
|
||||
expect(disks).toContain("/data · HDD");
|
||||
expect(disks).toContain("4.0 KB/s");
|
||||
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
|
||||
const top = screen.getByTestId("server-top-processes");
|
||||
expect(await within(top).findByText("Mia")).toBeTruthy();
|
||||
const other = screen.getByTestId("server-usage-table");
|
||||
expect(within(other).queryByText("Mia")).toBeNull();
|
||||
expect(within(other).getByText(/^Host account/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
|
||||
expect(api.host.history).toHaveBeenLastCalledWith("24h");
|
||||
});
|
||||
|
||||
it("shows each user's live CPU and RAM, their processes on click", async () => {
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
expect(tile.textContent).toContain("Mia");
|
||||
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"25"
|
||||
);
|
||||
expect(tile.textContent).toContain("2.0 GB");
|
||||
expect(within(tile).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
|
||||
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
|
||||
disks: [],
|
||||
usage: null,
|
||||
});
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: false,
|
||||
points: [],
|
||||
});
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText(/win32/)).toBeTruthy();
|
||||
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
|
||||
expect(card.textContent).not.toContain("Load");
|
||||
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
|
||||
for (const id of [
|
||||
"server-history",
|
||||
"server-cores",
|
||||
"server-top-processes",
|
||||
"server-disks-table",
|
||||
])
|
||||
expect(screen.queryByTestId(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,11 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless). The clone is a
|
||||
* background job (`lib/cloneJobs.ts`): the modal closes once the server
|
||||
* accepted it, the app-wide dock shows its queue place, git's progress and
|
||||
* outcome, and the new workspace is selected when it ends while the user is
|
||||
* still on that page. There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -119,8 +122,6 @@ export function AddLaneModal({
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
/** The running clone's progress token (CloneProgress polls it). */
|
||||
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -242,28 +243,46 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
const progressToken = newCloneToken();
|
||||
setCloneToken(progressToken);
|
||||
// The new workspace is selected when the clone ends only while the user
|
||||
// is still on the page they started it from; elsewhere the dock's
|
||||
// "Open workspace" does it.
|
||||
const from = window.location.pathname;
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
progressToken,
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
await startClone(
|
||||
{
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onDone: (job, lane) => {
|
||||
if (window.location.pathname !== from) return;
|
||||
if (lane) return onAdded(lane);
|
||||
const id = job.result?.laneId;
|
||||
if (id == null) return;
|
||||
api.lanes
|
||||
.get(id)
|
||||
.then((r) => onAdded(r.lane))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
}
|
||||
);
|
||||
// Accepted: the dock carries the clone from here.
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -284,8 +303,6 @@ export function AddLaneModal({
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setCloneToken(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -571,11 +588,7 @@ export function AddLaneModal({
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -856,7 +869,7 @@ export function AddLaneModal({
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
? t("cloneJobs.starting")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||
* to its allowlist and the folder to the user's own, and clones as the
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||
* A running clone shows `CloneProgress` and never holds the page: closing the
|
||||
* dialog only hides it behind a small progress chip (click to reopen), the
|
||||
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||
* for the next open so a login row is still there to fill in.
|
||||
* a workspace on the new folder. The clone is a server-side background job
|
||||
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
|
||||
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
|
||||
* outcome, across pages, F5 and server restarts. A refusal before the clone
|
||||
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
|
||||
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
|
||||
* remembered), or a pointer to Settings → Git.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,12 +19,9 @@ import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -31,16 +29,11 @@ export function CloneHereDialog({
|
||||
open,
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
onReopen,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
/** Show the dialog again from the background chip. */
|
||||
onReopen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,23 +44,10 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||
const [target, setTarget] = useState("");
|
||||
// Read by the request's own continuation and by the reset below.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const busyRef = useRef(false);
|
||||
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||
const keepRef = useRef(false);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || busyRef.current) return;
|
||||
if (keepRef.current) {
|
||||
keepRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
@@ -76,67 +56,42 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
if (!open) return null;
|
||||
|
||||
if (!open) {
|
||||
if (!busy || !token) return null;
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="fx-clone-chip"
|
||||
onClick={onReopen}
|
||||
title={into}
|
||||
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("clone.cloning")} {into.split("/").pop()}
|
||||
</span>
|
||||
</span>
|
||||
<CloneProgress token={token} />
|
||||
</button>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
const progressToken = newCloneToken();
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setToken(progressToken);
|
||||
setTarget(into);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
progressToken,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
onCloned(result.lane, result.path);
|
||||
// Accepted: the dock takes it from here.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
let message: string;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
@@ -145,18 +100,11 @@ export function CloneHereDialog({
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
message = t("clone.sshRefused", { host });
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setError(message);
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
if (!openRef.current) {
|
||||
keepRef.current = true;
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,7 +206,6 @@ export function CloneHereDialog({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{busy && token && <CloneProgress token={token} />}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
@@ -269,9 +216,10 @@ export function CloneHereDialog({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{busy ? t("clone.background") : t("fx.cancel")}
|
||||
{t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -279,7 +227,7 @@ export function CloneHereDialog({
|
||||
className="btn-primary text-xs"
|
||||
data-testid="fx-clone-submit"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,59 +2,35 @@
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||
* yet arrived, or an older server — just shows nothing yet.
|
||||
* with a bar. It renders the progress fields of a background clone job
|
||||
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type CloneProgressView } from "../../lib/api";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
export type CloneProgressFields = Pick<
|
||||
CloneJob,
|
||||
"state" | "position" | "phase" | "label" | "percent" | "detail"
|
||||
> & { running?: number; waiting?: number; limit?: number };
|
||||
|
||||
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||
export function newCloneToken(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function CloneProgress({ token }: { token: string }) {
|
||||
export function CloneProgress({ view }: { view: CloneProgressFields }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setView(null);
|
||||
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||
const poll = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.cloneProgress(token))
|
||||
.then((v) => {
|
||||
if (alive) setView(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||
if (view.state !== "queued" && view.state !== "running") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
|
||||
? t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})
|
||||
: t("cloneJobs.queued", { position: view.position ?? 1 })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{view.detail}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
|
||||
@@ -10,16 +10,19 @@
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
|
||||
* the server accepted it the app-wide dock shows its queue place, git's
|
||||
* progress and outcome (it survives leaving the page, F5 and a server
|
||||
* restart), and the folder list is re-read when it ends; **Partial clone**
|
||||
* sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Row = {
|
||||
url: string;
|
||||
@@ -27,8 +30,6 @@ type Row = {
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
/** While busy: the progress token CloneProgress polls. */
|
||||
token?: string;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
// A background clone into THIS workspace ended: its folder is now in the
|
||||
// list (the server added it), so read the list again.
|
||||
const onAddedRef = useRef(onAdded);
|
||||
onAddedRef.current = onAdded;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
const job = (e as CustomEvent<CloneJob>).detail;
|
||||
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.sources(laneId))
|
||||
.then((view) => onAddedRef.current(view))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
row.token = newCloneToken();
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
progressToken: row.token,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "source",
|
||||
laneId,
|
||||
body: {
|
||||
url: row.url,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Accepted: the dock shows its progress; the folder list is re-read
|
||||
// when it ends (the CLONE_JOB_EVENT listener above).
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
{r.state === "done" && (
|
||||
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
|
||||
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
|
||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
|
||||
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
|
||||
* upload keeps going when the user opens Trash or another page, and can be
|
||||
* stopped there; New → Clone repository runs as a background clone job;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
@@ -90,12 +94,17 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { CLONE_JOB_EVENT } from "../../lib/cloneJobs";
|
||||
import { beginUpload } from "../../lib/uploadJobs";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
@@ -166,6 +175,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
||||
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const errCode = (e: unknown) => (e as { code?: string })?.code;
|
||||
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
|
||||
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
|
||||
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
|
||||
const CODE = new Set([
|
||||
@@ -225,6 +235,8 @@ async function walkEntry(
|
||||
out.push({ file, rel: `${prefix}${entry.name}` });
|
||||
return;
|
||||
}
|
||||
// The server takes nothing into .git: never read one off the disk at all.
|
||||
if (entry.name === ".git") return;
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
// readEntries answers in pages until it answers [].
|
||||
for (;;) {
|
||||
@@ -321,13 +333,6 @@ export function FileBrowser({
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
const [askAll, setAskAll] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
name: string;
|
||||
done: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
bytes: number;
|
||||
} | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const { state: auth } = useAuth();
|
||||
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
@@ -363,16 +368,21 @@ export function FileBrowser({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [laneId, initialHome, session, source, initialPath]);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-read after every change here too: deleting a workspace's folder takes
|
||||
// it out of the places (a workspace whose folder is gone has nothing to show).
|
||||
const loadLanes = useCallback(() => {
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadLanes();
|
||||
api.files
|
||||
.homes()
|
||||
.then((r) => setHomes(r.homes))
|
||||
.catch(() => setHomes([]));
|
||||
}, []);
|
||||
}, [loadLanes]);
|
||||
// Another user's home (an admin's view) is read-only.
|
||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||
@@ -433,6 +443,8 @@ export function FileBrowser({
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// Never leave the last folder's entries under another folder's error.
|
||||
if (view === "files") setListing(null);
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
setError(
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
@@ -449,8 +461,20 @@ export function FileBrowser({
|
||||
|
||||
const changed = () => {
|
||||
void load();
|
||||
loadLanes();
|
||||
onChanged?.();
|
||||
};
|
||||
// A background clone finished (the dock announced it): its folder and
|
||||
// workspace are new here, wherever it was started from.
|
||||
const changedRef = useRef(changed);
|
||||
changedRef.current = changed;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
if ((e as CustomEvent<CloneJob>).detail?.state === "done") changedRef.current();
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, []);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = listing?.entries ?? [];
|
||||
@@ -461,13 +485,28 @@ export function FileBrowser({
|
||||
}, [listing, showHidden, query]);
|
||||
const selectedEntries = entries.filter((e) => selected.has(e.path));
|
||||
|
||||
// An upload outlives this view (its progress is in the dock): once the view
|
||||
// is gone nobody can answer a name clash, so it keeps both copies.
|
||||
const mountedRef = useRef(true);
|
||||
const pendingAsk = useRef<Ask["resolve"] | null>(null);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
pendingAsk.current?.({ choice: "keep", all: true });
|
||||
pendingAsk.current = null;
|
||||
};
|
||||
}, []);
|
||||
/** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
|
||||
const askConflict = (names: string[], many: boolean) =>
|
||||
new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
|
||||
if (!mountedRef.current) return resolve({ choice: "keep", all: true });
|
||||
pendingAsk.current = resolve;
|
||||
setAskAll(false);
|
||||
setAsk({ names, many, resolve });
|
||||
});
|
||||
const answer = (choice: FsConflict | null) => {
|
||||
pendingAsk.current = null;
|
||||
ask?.resolve({ choice, all: askAll });
|
||||
setAsk(null);
|
||||
};
|
||||
@@ -498,8 +537,11 @@ export function FileBrowser({
|
||||
};
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────
|
||||
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
|
||||
const [viewing, setViewing] = useState<FsEntry | null>(null);
|
||||
const openEntry = (e: FsEntry) => {
|
||||
if (isFolder(e)) go(e.path);
|
||||
else if (isHtml(e)) setViewing(e);
|
||||
else download([e]);
|
||||
};
|
||||
const goUp = () => {
|
||||
@@ -607,8 +649,14 @@ export function FileBrowser({
|
||||
};
|
||||
|
||||
/** Upload `files` (each with its path inside the pick/drop) under `dir`. */
|
||||
const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
|
||||
const uploadAll = async (picked: { file: File; rel: string }[], dir = path) => {
|
||||
setMenu(null);
|
||||
// A dropped/picked repo carries its .git, which the server refuses file by
|
||||
// file — skip it here with ONE notice instead of a toast per object.
|
||||
const files = picked.filter((f) => !f.rel.split("/").includes(".git"));
|
||||
if (files.length < picked.length) {
|
||||
toast.info(t("fx.gitSkipped", { count: picked.length - files.length }));
|
||||
}
|
||||
if (!files.length) return;
|
||||
const bytes = files.reduce((n, f) => n + f.file.size, 0);
|
||||
if (bytes > FX_BATCH_MAX_BYTES) {
|
||||
@@ -618,19 +666,29 @@ export function FileBrowser({
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// The place is fixed now: the user may open another folder (or leave the
|
||||
// page) while this keeps sending; its progress lives in the dock.
|
||||
const place = target;
|
||||
const job = beginUpload({
|
||||
folder: `${placeName(place)}${dir ? `/${dir}` : ""}`,
|
||||
total: files.length,
|
||||
bytes,
|
||||
});
|
||||
const { signal } = job;
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) =>
|
||||
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -641,7 +699,7 @@ export function FileBrowser({
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") return false;
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
@@ -661,9 +719,10 @@ export function FileBrowser({
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
target,
|
||||
place,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
@@ -674,7 +733,7 @@ export function FileBrowser({
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -685,7 +744,7 @@ export function FileBrowser({
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL") return "cancel";
|
||||
if (code === "ECANCEL" || code === "EABORT") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
@@ -709,9 +768,10 @@ export function FileBrowser({
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
job.end();
|
||||
setBusy(false);
|
||||
if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
if (signal.aborted) toast.info(t("uploads.stopped", { count: saved, total: files.length }));
|
||||
else if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
changed();
|
||||
}
|
||||
};
|
||||
@@ -917,11 +977,18 @@ export function FileBrowser({
|
||||
// owner whose home this viewer may not open (an analyst's view of other
|
||||
// users) still gets a row, one that only groups. A workspace with no owner
|
||||
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||
// A workspace whose folder was deleted has nothing to browse — it stays a
|
||||
// place only while it is the one open (so its error has a row to sit on).
|
||||
const placeLanes = useMemo(
|
||||
() =>
|
||||
lanes.filter((l) => !l.folder_missing || (!isHomePlace(target) && laneOf(target) === l.id)),
|
||||
[lanes, target]
|
||||
);
|
||||
const { lanesOfHome, userRows } = useMemo(() => {
|
||||
const ids = new Set(homes.map((h) => h.id));
|
||||
const map = new Map<string, Lane[]>();
|
||||
const extra = new Map<string, UserRow>();
|
||||
for (const l of lanes) {
|
||||
for (const l of placeLanes) {
|
||||
const home =
|
||||
myId != null && l.owner_id === myId && ids.has("me")
|
||||
? "me"
|
||||
@@ -944,9 +1011,9 @@ export function FileBrowser({
|
||||
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
return { lanesOfHome: map, userRows: rows };
|
||||
}, [homes, lanes, myId]);
|
||||
}, [homes, placeLanes, myId]);
|
||||
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
|
||||
const looseLanes = placeLanes.filter((l) => !grouped.has(l.id));
|
||||
// A home holding the open workspace shows it, whatever was stored.
|
||||
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||
openHomes.has(id) ||
|
||||
@@ -970,20 +1037,17 @@ export function FileBrowser({
|
||||
onClick={() => setMenu(null)}
|
||||
data-testid="file-explorer"
|
||||
>
|
||||
{cloneParent && (
|
||||
<CloneHereDialog
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onReopen={() => setCloning(true)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
||||
changed();
|
||||
}}
|
||||
{viewing && (
|
||||
<HtmlViewer
|
||||
entry={viewing}
|
||||
place={target}
|
||||
onDownload={() => download([viewing])}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
{cloneParent && (
|
||||
<CloneHereDialog open={cloning} parent={cloneParent} onClose={() => setCloning(false)} />
|
||||
)}
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||
@@ -1630,25 +1694,6 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div
|
||||
data-testid="fx-progress"
|
||||
className="absolute bottom-12 right-4 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-xl"
|
||||
>
|
||||
<p className="truncate text-xs font-medium">
|
||||
{t("fx.uploading", { done: progress.done + 1, total: progress.total })}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-fg-muted">{progress.name}</p>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{
|
||||
width: `${Math.min(100, (progress.sent / Math.max(1, progress.bytes)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
@@ -1656,13 +1701,16 @@ export function FileBrowser({
|
||||
{menuItem ? (
|
||||
<>
|
||||
<MenuItem onSelect={close(() => openEntry(menuItem))}>
|
||||
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
{isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
</MenuItem>
|
||||
{(isFolder(menuItem) || menuTargets.length > 1) && (
|
||||
<MenuItem onSelect={close(() => download(menuTargets))}>
|
||||
{t("fx.downloadZip")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{!ro && (
|
||||
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
|
||||
@@ -1916,6 +1964,36 @@ export function FileExplorer({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
|
||||
* its relative CSS, scripts and images read through the same download route. */
|
||||
function HtmlViewer({
|
||||
entry,
|
||||
place,
|
||||
onDownload,
|
||||
onClose,
|
||||
}: {
|
||||
entry: FsEntry;
|
||||
place: FsPlace;
|
||||
onDownload: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const placeKey = JSON.stringify(place);
|
||||
const fetchFile = useMemo(
|
||||
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[placeKey]
|
||||
);
|
||||
return (
|
||||
<HtmlViewerModal
|
||||
path={entry.path}
|
||||
fetchFile={fetchFile}
|
||||
onDownload={onDownload}
|
||||
onClose={onClose}
|
||||
testId="fx-html-viewer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
* 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
|
||||
* whole markdown file can also be read rendered (Preview), an HTML
|
||||
* file's Preview beside Whole file opens the page in a modal
|
||||
* (`HtmlViewerModal`, its relative CSS, scripts and images read
|
||||
* like downloads), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* history — the commit graph (`git/HistoryView.tsx`: all branches, search,
|
||||
* details, multi-select cherry-pick/revert, compare, browse at a
|
||||
* revision); Show History on a file is `git/FileHistory.tsx`
|
||||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||||
* mode switcher's Files button; at full width the sidebar);
|
||||
* a row dragged onto a prompt box attaches it as `@path`
|
||||
@@ -51,9 +56,16 @@
|
||||
* .gitignore (`ignore`, untracked only). While a merge is open the box is
|
||||
* hidden: a partial commit cannot finish one, Complete does.
|
||||
*
|
||||
* 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
|
||||
* **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with
|
||||
* an optional one-line note), and the Shelves list (`LaneShelves`) above the
|
||||
* file list restores or deletes what was shelved — a git stash under the
|
||||
* hood, its recovery always a button here.
|
||||
*
|
||||
* History rewrites through HistoryView's commit menu and selection: Revert and
|
||||
* Cherry-pick (one or several commits, previewed in `CherryPickDialog`, a
|
||||
* paused one finished from `PickBanner`), Undo commit (the top one; the
|
||||
* server refuses a pushed one), Reset branch to here… and Edit commits after
|
||||
* this one… (`GitTools`). The commit row's Amend box
|
||||
* folds the ticked files into the last commit (none ticked: rewords it). All
|
||||
* four ask first (`confirmDialog`).
|
||||
*
|
||||
@@ -77,9 +89,19 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -127,10 +149,17 @@ import { LaneConflictView } from "./LaneConflictView";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
import { HistoryView } from "./git/HistoryView";
|
||||
import { FileHistory } from "./git/FileHistory";
|
||||
import { PickBanner } from "./git/PickBanner";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
LaneChangeRange,
|
||||
@@ -150,6 +179,10 @@ const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
|
||||
|
||||
type Mode = "overview" | "review" | "file" | "history" | "browse";
|
||||
|
||||
/** A cherry-pick or revert: its banner is PickBanner, not the merge one (a
|
||||
* merge and a rebase keep theirs). */
|
||||
const isPick = (op: string | null | undefined) => op === "cherry-pick" || op === "revert";
|
||||
|
||||
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
|
||||
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
|
||||
* modified (and renamed) blue. */
|
||||
@@ -185,6 +218,9 @@ function Delta({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
|
||||
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||
// CodeMirror is loaded only once a file is edited.
|
||||
@@ -453,8 +489,9 @@ function FileActions({
|
||||
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`}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("changes.discardShortConfirm")}
|
||||
</button>
|
||||
) : (
|
||||
@@ -754,54 +791,6 @@ function ChangeTreeNodes({
|
||||
);
|
||||
}
|
||||
|
||||
/** History's page size, and the most the server reads (`listCommits`). */
|
||||
const HISTORY_PAGE = 50;
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
// The history's commit graph: one fixed-height row per commit so its lanes
|
||||
// join up row to row. Mid-tone hues that read on dark and light themes alike.
|
||||
const GRAPH_ROW = 34;
|
||||
const GRAPH_COL = 12;
|
||||
const GRAPH_COLORS = [
|
||||
"#3b82f6",
|
||||
"#22c55e",
|
||||
"#f59e0b",
|
||||
"#a855f7",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
"#ec4899",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={width * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
@@ -844,6 +833,8 @@ export function LaneChangesPanel({
|
||||
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, source);
|
||||
// The open history-rewrite / remote / merge-editor tool (./git/GitTools).
|
||||
const [gitTool, setGitTool] = useState<GitTool | null>(null);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
@@ -859,6 +850,11 @@ export function LaneChangesPanel({
|
||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
// Any git write on this workspace — from this panel's bar, another repo's
|
||||
// panel or the session view — re-reads everything here (`rereadAll`, set
|
||||
// once it is defined below).
|
||||
const rereadRef = useRef<(() => void) | null>(null);
|
||||
useGitChanged(laneId, () => rereadRef.current?.());
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
// Local mode: the files are already on this machine — no upload, download
|
||||
// or File Explorer (the desktop's file manager does all three).
|
||||
@@ -874,10 +870,13 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file, each
|
||||
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
// A whole markdown file shown rendered instead of as source; per file,
|
||||
// opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
|
||||
const [htmlOpen, setHtmlOpen] = useState(false);
|
||||
useEffect(() => setHtmlOpen(false), [openFile, mode]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
@@ -889,7 +888,6 @@ export function LaneChangesPanel({
|
||||
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
|
||||
setMode(full ? "overview" : "browse");
|
||||
}, [notRepo, mode, full]);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
|
||||
@@ -959,6 +957,9 @@ export function LaneChangesPanel({
|
||||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||||
const pendingScroll = useRef<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
// Shelve (n) opened its note row; the note typed there.
|
||||
const [shelving, setShelving] = useState(false);
|
||||
const [shelfNote, setShelfNote] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// The last commit is already on its upstream: the server refuses to amend
|
||||
@@ -1056,30 +1057,8 @@ export function LaneChangesPanel({
|
||||
}, [load, mode]);
|
||||
|
||||
// History and the file tree are their own reads, each fetched when its mode
|
||||
// is entered rather than alongside the diff nobody is looking at.
|
||||
// History reads HISTORY_PAGE commits, and Load more asks for the next page
|
||||
// (up to the server's 200).
|
||||
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
// A reply for the previous ref/file/page lands after the next request
|
||||
// started: drop it, or it overwrites the list the user is looking at.
|
||||
let live = true;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, {
|
||||
limit: historyLimit,
|
||||
...(historyRef ? { ref: historyRef } : {}),
|
||||
...(historyFile ? { file: historyFile } : {}),
|
||||
})
|
||||
.then(forLane(laneId, (r) => live && setCommits(r.commits)))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
||||
// is entered rather than alongside the diff nobody is looking at. The commit
|
||||
// graph and a file's history read themselves (git/HistoryView, FileHistory).
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
let live = true;
|
||||
@@ -1246,7 +1225,6 @@ export function LaneChangesPanel({
|
||||
setTreeBusy(false);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setCommits(null);
|
||||
setData(null);
|
||||
setDraft(null);
|
||||
setCollapsed(new Set());
|
||||
@@ -1327,7 +1305,6 @@ export function LaneChangesPanel({
|
||||
if (!ok) return;
|
||||
setHistoryFile(path);
|
||||
setHistoryRef(null);
|
||||
setCommits(null);
|
||||
setMode("history");
|
||||
});
|
||||
};
|
||||
@@ -1450,25 +1427,78 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
// Discard selected: exactly what Commit would take — the ticked files, and
|
||||
// only the ticked blocks of a partly ticked one (#87: it used to discard
|
||||
// every file, ticked or not) — in one `discard`, asked once in a dialog
|
||||
// that names how many untracked files it will delete.
|
||||
const discardTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
// Tracked and untracked counted apart: an untracked file is deleted, not
|
||||
// reverted, and counting it in both told "10 changed + 10 new" for 10.
|
||||
const untracked = files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length;
|
||||
const tracked = tickedPaths.length - untracked;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
title: t("changes.discardTickedTitle"),
|
||||
message: t(
|
||||
untracked === 0
|
||||
? "changes.discardTickedConfirmTracked"
|
||||
: tracked === 0
|
||||
? "changes.discardTickedConfirmNew"
|
||||
: "changes.discardTickedConfirm",
|
||||
{ count: tracked, untracked }
|
||||
),
|
||||
confirmLabel: t("changes.discardTicked", { count: tickedPaths.length }),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
if (!ok) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { paths, hunks } = await selection();
|
||||
await api.lanes.gitAction(target, "discard", {
|
||||
paths,
|
||||
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
|
||||
confirm: true,
|
||||
});
|
||||
if (laneNow.current !== lane) return;
|
||||
setConfirmDiscard(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shelve (n): the ticked files (whole files — a stash has no hunks) set
|
||||
// aside with the note; all of them when every file is ticked.
|
||||
const shelveTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const all = tickedPaths.length >= files.length;
|
||||
const res = await withAgentConfirm((confirm) =>
|
||||
api.lanes.gitAction(target, "shelve", {
|
||||
...(all ? {} : { paths: tickedPaths }),
|
||||
...(shelfNote.trim() ? { note: shelfNote.trim() } : {}),
|
||||
...(confirm ? { confirm } : {}),
|
||||
})
|
||||
);
|
||||
if (laneNow.current !== lane || !res) return;
|
||||
toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length }));
|
||||
setShelving(false);
|
||||
setShelfNote("");
|
||||
refreshGit();
|
||||
setReread((n) => n + 1);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
@@ -1558,7 +1588,6 @@ export function LaneChangesPanel({
|
||||
|
||||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||||
const commitCtx = useContextMenu<LaneCommit>();
|
||||
const viewCommit = (sha: string) => {
|
||||
setSha(sha);
|
||||
setRange("commit");
|
||||
@@ -1734,68 +1763,15 @@ export function LaneChangesPanel({
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{commitCtx.menu && (
|
||||
<ContextMenu
|
||||
x={commitCtx.menu.x}
|
||||
y={commitCtx.menu.y}
|
||||
onClose={commitCtx.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.copySha")}
|
||||
</MenuItem>
|
||||
<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
|
||||
// Pushed: the server refuses it (EPUSHED), so say why here
|
||||
// instead of failing after the confirm.
|
||||
disabled={
|
||||
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
|
||||
}
|
||||
title={headPushed ? t("changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("undo-commit", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
<GitTools
|
||||
tool={gitTool}
|
||||
target={target}
|
||||
branch={git?.available ? git.branch : null}
|
||||
onClose={() => setGitTool(null)}
|
||||
onOpen={setGitTool}
|
||||
onChanged={() => void rereadAll()}
|
||||
onViewCommit={viewCommit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1977,6 +1953,7 @@ export function LaneChangesPanel({
|
||||
setReread((n) => n + 1);
|
||||
return load();
|
||||
};
|
||||
rereadRef.current = () => void rereadAll();
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||||
@@ -2044,6 +2021,13 @@ export function LaneChangesPanel({
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||
// from the working tree: a commit's page would get today's files.
|
||||
const htmlFetch = useMemo(
|
||||
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[range, JSON.stringify(target)]
|
||||
);
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
@@ -2369,10 +2353,7 @@ export function LaneChangesPanel({
|
||||
aria-pressed={mode === m}
|
||||
onClick={() => {
|
||||
// The History button is the branch's; Show History, a file's.
|
||||
if (m === "history" && historyFile) {
|
||||
setHistoryFile(null);
|
||||
setCommits(null);
|
||||
}
|
||||
if (m === "history" && historyFile) setHistoryFile(null);
|
||||
setMode(m);
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
@@ -2387,6 +2368,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!notRepo && (
|
||||
<GitToolsButton
|
||||
onPick={setGitTool}
|
||||
hasRemote={git?.available ? git.hasRemote : undefined}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runReread}
|
||||
@@ -2560,6 +2547,31 @@ export function LaneChangesPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* An HTML file opens as the page in a modal over the screen;
|
||||
a commit's page once its text is read (Whole file). */}
|
||||
{mode === "file" && isHtml(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
disabled={dirty || (range === "commit" && !text)}
|
||||
onClick={() => setHtmlOpen(true)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{htmlOpen && openFile && (
|
||||
<HtmlViewerModal
|
||||
path={openFile}
|
||||
// A commit's page is its text at that commit (no sibling
|
||||
// files); a working one is read whole from the disk.
|
||||
html={range === "commit" ? text?.lines.join("\n") : undefined}
|
||||
fetchFile={htmlFetch}
|
||||
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
|
||||
onClose={() => setHtmlOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2570,7 +2582,17 @@ export function LaneChangesPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{git?.available && git.merging && (
|
||||
{git?.available && git.merging && isPick(git.operation) && (
|
||||
<PickBanner
|
||||
target={target}
|
||||
conflicts={git.conflicts ?? 0}
|
||||
reread={reread}
|
||||
busy={busy}
|
||||
onBusy={setBusy}
|
||||
onDone={() => void rereadAll()}
|
||||
/>
|
||||
)}
|
||||
{git?.available && git.merging && !isPick(git.operation) && (
|
||||
<div
|
||||
data-testid="lane-merge-banner"
|
||||
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||||
@@ -2588,6 +2610,13 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
{git.operation === "rebase" && (
|
||||
<RebaseSkipButton
|
||||
target={target}
|
||||
disabled={busy}
|
||||
onChanged={() => void rereadAll()}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
@@ -2604,6 +2633,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes set aside (git stash entries AgentHub made): Restore and
|
||||
Delete, never dropped on their own. Absent while there are none. */}
|
||||
{git?.available && mode !== "file" && (
|
||||
<LaneShelves target={target} reread={reread} onChanged={rereadAll} />
|
||||
)}
|
||||
|
||||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||||
bar sits at the bottom of the visible box instead of at the bottom of
|
||||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||||
@@ -2616,103 +2651,40 @@ export function LaneChangesPanel({
|
||||
}`}
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history">
|
||||
{historyFile && (
|
||||
<li
|
||||
data-testid="lane-changes-history-file"
|
||||
className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("changes.historyOfFile", { path: historyFile })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHistoryFile(null);
|
||||
setCommits(null);
|
||||
}}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("changes.historyWholeBranch")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{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);
|
||||
return list.map((c, i) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
historyFile ? openAtCommit(historyFile, c.sha) : viewCommit(c.sha)
|
||||
}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
style={{ height: GRAPH_ROW }}
|
||||
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||||
>
|
||||
<GraphCell row={graph.rows[i]!} width={graph.width} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{c.short}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
|
||||
<span className="truncate">{c.author}</span>
|
||||
<span>{new Date(c.date).toLocaleString()}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
));
|
||||
})()}
|
||||
{commits === null && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||||
)}
|
||||
{commits && commits.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||||
)}
|
||||
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={historyLoading}
|
||||
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
historyFile ? (
|
||||
<FileHistory
|
||||
key={`${laneId}:${historyFile}`}
|
||||
target={target}
|
||||
path={historyFile}
|
||||
reread={reread}
|
||||
onOpenVersion={openAtCommit}
|
||||
onViewCommit={viewCommit}
|
||||
onWholeBranch={() => setHistoryFile(null)}
|
||||
/>
|
||||
) : (
|
||||
<HistoryView
|
||||
key={laneId}
|
||||
target={target}
|
||||
branch={git?.available ? git.branch : null}
|
||||
branches={historyBranches}
|
||||
historyRef={historyRef}
|
||||
onHistoryRef={setHistoryRef}
|
||||
reread={reread}
|
||||
disabled={busy || opOpen}
|
||||
headPushed={headPushed}
|
||||
onViewCommit={viewCommit}
|
||||
onOpenFileAtCommit={openAtCommit}
|
||||
onUndoCommit={(c) => void historyAct("undo-commit", c)}
|
||||
onResetHere={(c) => setGitTool({ kind: "reset", commit: c })}
|
||||
onEditAfter={(sha) => setGitTool({ kind: "interactive", base: sha })}
|
||||
onPaused={() => {
|
||||
setRange("work");
|
||||
setMode("overview");
|
||||
void rereadAll();
|
||||
}}
|
||||
onChanged={() => void rereadAll()}
|
||||
/>
|
||||
)
|
||||
) : mode === "browse" && !full ? (
|
||||
browsePane
|
||||
) : !data ? (
|
||||
@@ -2810,15 +2782,62 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void discardTicked()}
|
||||
title={t("changes.discardTickedTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
{t("changes.discardTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-shelve"
|
||||
aria-expanded={shelving}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => setShelving((v) => !v)}
|
||||
title={t("shelves.shelveHint")}
|
||||
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"
|
||||
>
|
||||
<Archive className="h-3 w-3" aria-hidden />
|
||||
{t("shelves.shelveTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
</div>
|
||||
{shelving && (
|
||||
<div
|
||||
data-testid="lane-changes-shelve-row"
|
||||
className="flex w-full flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={shelfNote}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
onChange={(e) => setShelfNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) void shelveTicked();
|
||||
if (e.key === "Escape") setShelving(false);
|
||||
}}
|
||||
placeholder={t("shelves.notePlaceholder")}
|
||||
aria-label={t("shelves.notePlaceholder")}
|
||||
className="min-w-[10rem] 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
|
||||
type="button"
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void shelveTicked()}
|
||||
className="shrink-0 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("shelves.shelveApply", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShelving(false)}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("shelves.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The work range splits like JetBrains' Commit window: tracked
|
||||
@@ -2939,6 +2958,9 @@ export function LaneChangesPanel({
|
||||
editWanted.current = true;
|
||||
setWhole(true);
|
||||
}}
|
||||
onMergeEditor={() =>
|
||||
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
|
||||
}
|
||||
/>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) || openImage ? (
|
||||
@@ -2969,6 +2991,7 @@ export function LaneChangesPanel({
|
||||
{t("changes.annotate")}
|
||||
</button>
|
||||
)}
|
||||
{/* An HTML file's Preview sits beside Whole file instead. */}
|
||||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
|
||||
* `resolve done`, which re-checks that no marker is left. Take all mine /
|
||||
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
|
||||
* file to the panel's plain editor.
|
||||
* file to the panel's plain editor; Merge editor opens the 3-way editor
|
||||
* (`./git/MergeEditor`, base/current/incoming side by side).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -42,7 +43,10 @@ export function LaneConflictView({
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
onMergeEditor,
|
||||
}: {
|
||||
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
|
||||
onMergeEditor?: () => void;
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
@@ -176,6 +180,17 @@ export function LaneConflictView({
|
||||
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
||||
{t("conflict.editByHand")}
|
||||
</button>
|
||||
{onMergeEditor && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="conflict-open-merge-editor"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={onMergeEditor}
|
||||
>
|
||||
{t("gitTools:merge.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
|
||||
function Elided({ n }: { n?: number }) {
|
||||
const { t, i18n } = useTranslation("lanes");
|
||||
if (!n) return null;
|
||||
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
return (
|
||||
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||
{t("changes.lineElided", { size })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
@@ -544,6 +556,7 @@ function UnifiedLine({
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
<Elided n={line.elided} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -621,6 +634,7 @@ function SplitLine({
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
{line ? <Elided n={line.elided} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* fetch, pull, push, switch branch (a local one, or one only a remote has —
|
||||
* listed under "On the remote" and checked out as a new tracking branch; the
|
||||
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
@@ -13,7 +15,16 @@
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* --ff-only, switch refuses a dirty tree unless told what to do with the
|
||||
* changes), and this only reports what it said. With uncommitted changes,
|
||||
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
|
||||
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
|
||||
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
|
||||
* — and the new-branch row offers **Create & switch** (`switch: true`, the
|
||||
* changes come along; the default on Enter while the tree is dirty) beside
|
||||
* plain Create. A switch onto a branch that has shelves asks whether to
|
||||
* restore the newest (never automatically), and a write the server answers
|
||||
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
@@ -21,6 +32,7 @@
|
||||
* 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,
|
||||
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
|
||||
* The tags icon opens the Branches panel (`git/BranchManager`: every branch and tag, rename, upstream, tags).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,6 +55,8 @@ import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
import { restoreShelf } from "./LaneShelves";
|
||||
import { BranchesButton } from "./git/BranchesButton";
|
||||
|
||||
type Action =
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
@@ -114,6 +128,8 @@ export function LaneGitBar({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
// Fetched remote branches with no local one yet (`origin/feature`).
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
@@ -124,6 +140,9 @@ export function LaneGitBar({
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// A switch with uncommitted changes asks how to take them: set to the
|
||||
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
|
||||
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
@@ -138,6 +157,7 @@ export function LaneGitBar({
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setRemoteBranches([]);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
@@ -145,6 +165,7 @@ export function LaneGitBar({
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
setChoice(null);
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
@@ -152,6 +173,9 @@ export function LaneGitBar({
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
// A remote branch: switching creates its local tracking branch; it cannot
|
||||
// be deleted here or given a sibling worktree before that.
|
||||
const pickedRemote = remoteBranches.includes(picked);
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
@@ -160,6 +184,9 @@ export function LaneGitBar({
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
|
||||
// refused as a second action in flight.
|
||||
let askAgent = false;
|
||||
try {
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
@@ -182,17 +209,50 @@ export function LaneGitBar({
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setChoice(null);
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
} else if ((action === "fetch" || action === "pull") && branches !== null) {
|
||||
// A fetch may have brought branches the list does not show yet.
|
||||
await refreshBranches();
|
||||
}
|
||||
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
|
||||
onChanged();
|
||||
// The branch switched to has changes shelved on it: offer them back —
|
||||
// asking, never restoring on its own.
|
||||
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
|
||||
if (offer) {
|
||||
const yes = await confirmDialog({
|
||||
title: t("gitBar.restoreOfferTitle"),
|
||||
message: t("gitBar.restoreOffer", {
|
||||
branch: offer.branch,
|
||||
count: res?.shelves?.length ?? 1,
|
||||
files: offer.files.length,
|
||||
}),
|
||||
confirmLabel: t("shelves.restore"),
|
||||
destructive: false,
|
||||
});
|
||||
if (yes && laneNow.current === lane) {
|
||||
await restoreShelf(gitTarget(lane, session, source), offer);
|
||||
if (laneNow.current === lane) onChanged();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
|
||||
askAgent = true;
|
||||
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
|
||||
// git would overwrite these files: show them and offer the shelf.
|
||||
setChoice({
|
||||
branch: body.branch,
|
||||
conflict: (err as { files?: string[] }).files ?? [],
|
||||
});
|
||||
} else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
@@ -210,6 +270,15 @@ export function LaneGitBar({
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
if (askAgent) {
|
||||
const ok = await confirmDialog({
|
||||
title: t("shelves.agentTitle"),
|
||||
message: t("shelves.agentMessage"),
|
||||
confirmLabel: t("shelves.agentConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
@@ -217,6 +286,7 @@ export function LaneGitBar({
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setRemoteBranches(r.remotes || []);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
@@ -294,6 +364,20 @@ export function LaneGitBar({
|
||||
if (ok) void run("merge", { branch });
|
||||
};
|
||||
|
||||
// Switch branch: straight away on a clean tree; with changes, ask how.
|
||||
const switchTo = (branch: string) => {
|
||||
if (dirty > 0) {
|
||||
setChoice({ branch });
|
||||
setError(null);
|
||||
} else void run("switch", { branch });
|
||||
};
|
||||
// Enter in the new-branch box: with changes, Create & switch (they come
|
||||
// along); on a clean tree, plain Create as before.
|
||||
const createBranch = (andSwitch: boolean) => {
|
||||
const branch = newBranch.trim();
|
||||
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
@@ -412,17 +496,30 @@ export function LaneGitBar({
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
// With changes, Switch branch asks whether to bring or shelve
|
||||
// them; the hint says so before the click.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
? t("gitBar.switchDirtyHint", { count: dirty })
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
<BranchesButton
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
merging={merging}
|
||||
disabled={!!busy}
|
||||
onChanged={() => {
|
||||
// A rename, delete or new branch makes the switcher's list stale.
|
||||
if (branches !== null) void refreshBranches();
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -446,15 +543,26 @@ export function LaneGitBar({
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{remoteBranches.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{remoteBranches.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
// A branch git already has checked out somewhere else cannot be
|
||||
// switched to — git allows one worktree per branch.
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={
|
||||
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
|
||||
}
|
||||
onClick={() => switchTo(picked)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
@@ -476,7 +584,7 @@ export function LaneGitBar({
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
@@ -486,7 +594,7 @@ export function LaneGitBar({
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() =>
|
||||
void confirmRun(
|
||||
@@ -500,34 +608,83 @@ export function LaneGitBar({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && choice && (
|
||||
<div
|
||||
data-testid="lane-git-switch-choice"
|
||||
role="group"
|
||||
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
>
|
||||
<p className="break-words text-fg-primary">
|
||||
{choice.conflict
|
||||
? t("gitBar.carryConflict", { branch: choice.branch })
|
||||
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
|
||||
</p>
|
||||
{choice.conflict && choice.conflict.length > 0 && (
|
||||
<ul
|
||||
data-testid="lane-git-carry-conflicts"
|
||||
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
|
||||
>
|
||||
{choice.conflict.map((f) => (
|
||||
<li key={f} className="truncate" title={f}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{!choice.conflict && (
|
||||
<Btn
|
||||
label={t("gitBar.choiceCarry")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceCarryHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
label={t("gitBar.choiceShelve")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceShelveHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
|
||||
/>
|
||||
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
if (isSubmitEnter(e)) createBranch(dirty > 0);
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
aria-label={t("gitBar.branchNewPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreateSwitch")}
|
||||
icon={<GitBranch className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// The working tree stays as it is: uncommitted changes come along.
|
||||
title={t("gitBar.branchCreateSwitchHint")}
|
||||
onClick={() => createBranch(true)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreate")}
|
||||
icon={<Plus className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// Creating does not switch: the point is usually a sibling lane,
|
||||
// and moving this pane's HEAD under a working agent is not.
|
||||
// Plain Create does not switch: the point is usually a sibling
|
||||
// lane, and moving this pane's HEAD under a working agent is not.
|
||||
title={t("gitBar.branchCreateHint")}
|
||||
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
||||
onClick={() => createBranch(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* owned by the panel) and refresh each other after every write. Every other
|
||||
* reader of the facts (the rail counts, a second panel) hears a write through
|
||||
* `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +23,7 @@ import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, session, source, nonce]);
|
||||
// A git write from ANY view of this workspace (another panel, the session
|
||||
// view, a second tab of the same page) moves these facts too.
|
||||
useGitChanged(laneId, () => setNonce((n) => n + 1));
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
@@ -367,12 +373,17 @@ export function LaneGitHeader({
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
{/* A long branch name truncates too (after the subject has gone),
|
||||
or it overflowed onto the head and the action icons. */}
|
||||
<span
|
||||
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
|
||||
title={git.branch}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{git.branch}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @file The Changes panel's Shelves list: changes set aside by AgentHub (git
|
||||
* stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its
|
||||
* branch, note, time and files, and the two buttons that make a shelf safe to
|
||||
* offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only
|
||||
* when it applied cleanly; a conflicted restore keeps it and leaves the files
|
||||
* to the conflict list) and **Delete** (`shelf-drop`, two clicks through
|
||||
* `useArmed`, sent with `confirm: true`). Collapsed to one line until opened,
|
||||
* and absent while there are none. Also exports `withAgentConfirm`, the
|
||||
* one place a git write answered `EAGENTRUNNING` asks the user and is resent
|
||||
* with `confirm: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react";
|
||||
import i18n from "../../i18n";
|
||||
import { api, type GitShelf, type GitTarget } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
|
||||
/**
|
||||
* Run a git write; when the server answers `EAGENTRUNNING` (an agent of the
|
||||
* workspace is working and the write would move files under it), ask, and
|
||||
* resend it with `confirm: true`. Resolves null when the user declined.
|
||||
*/
|
||||
export async function withAgentConfirm<T>(
|
||||
send: (confirm: boolean) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await send(false);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err;
|
||||
const ok = await confirmDialog({
|
||||
title: i18n.t("lanes:shelves.agentTitle"),
|
||||
message: i18n.t("lanes:shelves.agentMessage"),
|
||||
confirmLabel: i18n.t("lanes:shelves.agentConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
return ok ? send(true) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by
|
||||
* the list below and the git bar's "restore what you shelved here" prompt.
|
||||
* @returns true when anything changed in the working tree.
|
||||
*/
|
||||
export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise<boolean> {
|
||||
try {
|
||||
const res = await withAgentConfirm((confirm) =>
|
||||
api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) })
|
||||
);
|
||||
if (!res) return false;
|
||||
if (res.conflicts?.length) {
|
||||
toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length }));
|
||||
} else {
|
||||
toast.success(i18n.t("lanes:shelves.restored"));
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
(err as { code?: string })?.code === "EDIRTY"
|
||||
? i18n.t("lanes:shelves.restoreDirty")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */
|
||||
function statusTone(status: string): string {
|
||||
if (status === "??" || status.startsWith("A")) return "text-status-success";
|
||||
if (status.startsWith("D")) return "text-status-danger";
|
||||
return "text-status-warning";
|
||||
}
|
||||
|
||||
export function LaneShelves({
|
||||
target,
|
||||
reread,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** Bumped by the panel after every git write: the list is read again. */
|
||||
reread: number;
|
||||
/** Called after a restore or a delete changed the tree or the list. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [shelves, setShelves] = useState<GitShelf[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filesOf, setFilesOf] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const del = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
const keyNow = useRef(key);
|
||||
keyNow.current = key;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const asked = key;
|
||||
try {
|
||||
const r = await api.lanes.gitShelves(target);
|
||||
if (keyNow.current === asked) setShelves(r.available ? r.shelves : []);
|
||||
} catch {
|
||||
if (keyNow.current === asked) setShelves([]);
|
||||
}
|
||||
// `target` is described by `key`; a new object with the same content is
|
||||
// the same tree.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, reread]);
|
||||
|
||||
if (!shelves.length) return null;
|
||||
|
||||
const restore = async (s: GitShelf) => {
|
||||
setBusy(s.sha);
|
||||
try {
|
||||
if (await restoreShelf(target, s)) onChanged();
|
||||
await load();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const drop = async (s: GitShelf) => {
|
||||
if (!del.isArmed(s.sha)) {
|
||||
del.arm(s.sha);
|
||||
return;
|
||||
}
|
||||
del.disarm();
|
||||
setBusy(s.sha);
|
||||
try {
|
||||
await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true });
|
||||
toast.success(t("shelves.deleted"));
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
await load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-testid="lane-shelves" className="mb-2 rounded border border-border-light">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[11px] font-medium text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
<Archive className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{t("shelves.title", { count: shelves.length })}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="divide-y divide-border-light border-t border-border-light">
|
||||
{shelves.map((s) => {
|
||||
const when = s.createdAt ? new Date(s.createdAt) : null;
|
||||
const showFiles = filesOf === s.sha;
|
||||
return (
|
||||
<li key={s.sha} data-testid="lane-shelf" className="px-2 py-1 text-[11px]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate font-mono font-semibold text-fg-primary"
|
||||
title={s.branch}
|
||||
>
|
||||
{s.branch}
|
||||
</span>
|
||||
{when && !Number.isNaN(when.getTime()) && (
|
||||
<span className="shrink-0 text-fg-muted" title={s.createdAt ?? undefined}>
|
||||
{when.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showFiles}
|
||||
onClick={() => setFilesOf(showFiles ? null : s.sha)}
|
||||
className="shrink-0 text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{t("shelves.files", { count: s.files.length })}
|
||||
</button>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void restore(s)}
|
||||
title={t("shelves.restoreHint")}
|
||||
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
{t("shelves.restore")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void drop(s)}
|
||||
title={t("shelves.deleteHint")}
|
||||
className="flex items-center gap-1 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{del.isArmed(s.sha) ? t("shelves.deleteConfirm") : t("shelves.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{s.note && (
|
||||
<p className="mt-0.5 break-words text-fg-secondary" title={s.note}>
|
||||
{s.note}
|
||||
</p>
|
||||
)}
|
||||
{showFiles && (
|
||||
<ul data-testid="lane-shelf-files" className="mt-1 space-y-0.5 pl-2">
|
||||
{s.files.map((f) => (
|
||||
<li key={f.path} className="flex min-w-0 items-center gap-1.5 font-mono">
|
||||
<span className={`w-4 shrink-0 text-center ${statusTone(f.status)}`}>
|
||||
{f.status === "??" ? "U" : f.status.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-fg-secondary" title={f.path}>
|
||||
{f.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneShelves;
|
||||
@@ -10,7 +10,7 @@
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
|
||||
* offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
@@ -145,7 +145,7 @@ export function SessionReplay({
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -172,7 +172,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -237,6 +239,17 @@ function HomeBody({
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
// A branch moved from the git bar: the main worktree's branch shown here is
|
||||
// the server's cached list, so it is refreshed rather than re-read.
|
||||
useGitChanged(lane.id, (action) => {
|
||||
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
|
||||
api.projects
|
||||
.refresh()
|
||||
.then(({ projects }) =>
|
||||
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
});
|
||||
|
||||
// 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.
|
||||
@@ -307,30 +320,30 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
const archiveSession = async (s: Session) => {
|
||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||||
const run = liveRunFor(lane, liveRuns, s.id);
|
||||
// A live one is stopped first, as the tab's X does (its own confirm):
|
||||
// archiving only its row left a pane running that no list showed.
|
||||
if (run) {
|
||||
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
|
||||
await patchSession(s, { archived: true });
|
||||
return;
|
||||
}
|
||||
const wt = sessionWorktreeName(lane, s);
|
||||
const ok = await confirmDialog(
|
||||
wt && !live
|
||||
wt
|
||||
? {
|
||||
title: t("tree.archiveWorktreeTitle"),
|
||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||
}
|
||||
: live
|
||||
? {
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
);
|
||||
if (!ok) return;
|
||||
if (wt && !live) {
|
||||
if (wt) {
|
||||
try {
|
||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||
|
||||
@@ -161,12 +161,19 @@ export function WorkspaceRow({
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
<span
|
||||
data-folder-missing={lane.folder_missing ? "true" : undefined}
|
||||
className={`truncate text-[13px] font-medium ${
|
||||
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||
@@ -32,7 +32,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
const archive = async (s: Session) => {
|
||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||
// A live one keeps running in its tab; archiving only hides its row.
|
||||
if (
|
||||
runFor(s) &&
|
||||
!(await confirmDialog({
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
// A live one is stopped first, as the tab's X does: archiving only its row
|
||||
// left a pane running that no list showed any more.
|
||||
const run = runFor(s);
|
||||
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
|
||||
await patch(s, { archived: true });
|
||||
};
|
||||
const archiveWithWorktree = async (s: Session) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -26,6 +27,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
@@ -158,6 +160,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -790,6 +794,54 @@ describe("AddLaneModal — Clone", () => {
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and selects the workspace when it ends", async () => {
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const job = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@github.com:owner/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an/code",
|
||||
target: "/home/an/code/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@github.com:owner/repo.git", parent: "/home/an/code", name: "repo" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [{ ...job, state: "done", result: { laneId: 5, path: "/home/an/code/repo" } }],
|
||||
});
|
||||
vi.mocked(api.lanes.get).mockResolvedValue({ lane: laneFixture({ id: 5 }) });
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded, onClose });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }))
|
||||
);
|
||||
// Accepted: the modal closes at once, before the clone is done.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// The store's next read sees it done: the new workspace is selected.
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })), {
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends partial only when the box is ticked", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 7 }),
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @file BranchManager.test.tsx
|
||||
* @description Pins the git bar's Branches panel: local branches with the
|
||||
* current one marked and ahead/behind shown, remote branches grouped by
|
||||
* remote, the search box, arrow-key walking, and each action's request —
|
||||
* rename, new branch from a remote branch (switch only offered on a clean
|
||||
* tree), set/unset upstream, merge into current, delete refused with its
|
||||
* reason — plus the Tags tab: annotated create, push one tag, delete with
|
||||
* `confirm: true` after the themed confirm, and git's error shown verbatim.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BranchManager } from "../git/BranchManager";
|
||||
import { api, type GitRefs } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number) => lane,
|
||||
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
|
||||
}));
|
||||
|
||||
const REFS: GitRefs = {
|
||||
available: true,
|
||||
current: "main",
|
||||
head: "aaaaaaa",
|
||||
truncated: false,
|
||||
local: [
|
||||
{
|
||||
name: "main",
|
||||
sha: "a1",
|
||||
current: true,
|
||||
upstream: "origin/main",
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
gone: false,
|
||||
subject: "latest work",
|
||||
date: null,
|
||||
merged: true,
|
||||
worktree: null,
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
name: "feature/very-long-branch-name",
|
||||
sha: "b2",
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
gone: false,
|
||||
subject: "feature commit",
|
||||
date: null,
|
||||
merged: false,
|
||||
worktree: null,
|
||||
protected: false,
|
||||
},
|
||||
{
|
||||
name: "held",
|
||||
sha: "c3",
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
gone: false,
|
||||
subject: "x",
|
||||
date: null,
|
||||
merged: true,
|
||||
worktree: "/tmp/other",
|
||||
protected: false,
|
||||
},
|
||||
],
|
||||
remotes: [
|
||||
{
|
||||
remote: "origin",
|
||||
branches: [
|
||||
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
|
||||
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "v1",
|
||||
sha: "a1b2c3d4",
|
||||
annotated: true,
|
||||
message: "release one",
|
||||
tagger: "T",
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function setup(over: { dirty?: number } = {}) {
|
||||
const onChanged = vi.fn();
|
||||
render(
|
||||
<BranchManager
|
||||
laneId={3}
|
||||
dirty={over.dirty ?? 0}
|
||||
merging={false}
|
||||
onClose={vi.fn()}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
return { onChanged };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
|
||||
vi.mocked(api.lanes.gitRefAction)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ action: "x", ok: true, output: "" });
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
});
|
||||
|
||||
const row = (name: RegExp) => screen.getByRole("button", { name });
|
||||
|
||||
describe("BranchManager", () => {
|
||||
it("lists local branches with the current marked and remote ones grouped", async () => {
|
||||
setup();
|
||||
await screen.findByText("feature/very-long-branch-name");
|
||||
const main = row(/^main/);
|
||||
expect(within(main).getByText("current")).toBeInTheDocument();
|
||||
expect(main).toHaveTextContent("2");
|
||||
expect(main).toHaveTextContent("1");
|
||||
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
|
||||
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
||||
// A long name truncates with its full text as the tooltip.
|
||||
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
|
||||
"title",
|
||||
"feature/very-long-branch-name"
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by the search box and walks rows with the arrow keys", async () => {
|
||||
setup();
|
||||
await screen.findByText("held");
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
|
||||
expect(screen.queryByText("held")).toBeNull();
|
||||
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
||||
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(row(/^main/)).toHaveFocus();
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(row(/^feature\/very-long/)).toHaveFocus();
|
||||
});
|
||||
|
||||
it("renames a branch and re-reads the list", async () => {
|
||||
const { onChanged } = setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
||||
const input = screen.getByRole("textbox", { name: "New name" });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "feature/short{Enter}");
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
name: "feature/short",
|
||||
});
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refuses rename and delete of a protected current branch, with the reason", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("main"));
|
||||
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
||||
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains a branch another worktree holds", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("held"));
|
||||
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
||||
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes an unmerged branch only after the confirm warns", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
|
||||
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
|
||||
setup({ dirty: 2 });
|
||||
await userEvent.click(await screen.findByText("origin/remote-only"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
|
||||
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
|
||||
const input = screen.getByRole("textbox", { name: "New branch name" });
|
||||
expect(input).toHaveValue("remote-only");
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "remote-only",
|
||||
from: "origin/remote-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets and unsets an upstream, and merges into the current branch", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
|
||||
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
upstream: "origin/remote-only",
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
});
|
||||
await userEvent.click(screen.getByText("main"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
|
||||
});
|
||||
|
||||
it("shows git's own words when a write is refused", async () => {
|
||||
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
|
||||
);
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
|
||||
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
|
||||
"branch x already exists"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagManager (Tags tab)", () => {
|
||||
const openTags = async () => {
|
||||
setup();
|
||||
await screen.findByText("main");
|
||||
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
|
||||
};
|
||||
|
||||
it("lists tags with their message and creates an annotated one", async () => {
|
||||
await openTags();
|
||||
expect(screen.getByText("v1")).toBeInTheDocument();
|
||||
expect(screen.getByText(/release one/)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
|
||||
await userEvent.type(
|
||||
screen.getByRole("textbox", { name: /^On / }),
|
||||
"feature/very-long-branch-name"
|
||||
);
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
|
||||
tag: "v2",
|
||||
message: "notes",
|
||||
from: "feature/very-long-branch-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes one tag and deletes one only with confirm: true", async () => {
|
||||
await openTags();
|
||||
await userEvent.click(screen.getByText("v1"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
|
||||
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
||||
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
|
||||
tag: "v1",
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens from the git bar's button and closes on Escape", async () => {
|
||||
const { BranchesButton } = await import("../git/BranchesButton");
|
||||
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
|
||||
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
|
||||
await screen.findByText("main");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByTestId("branch-manager")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not delete when the confirm is cancelled", async () => {
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await openTags();
|
||||
await userEvent.click(screen.getByText("v1"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
||||
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,19 @@
|
||||
/**
|
||||
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||
* server's clone queue, a running one git's phase, percent and speed with a
|
||||
* bar, and nothing shows before the server knows the token. Also that
|
||||
* `newCloneToken` makes a token the server accepts.
|
||||
* server's clone queue (with the machine-wide counts when it has them), a
|
||||
* running one git's phase, percent and speed with a bar, and a finished one
|
||||
* nothing — all rendered from a clone job's fields, no fetching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { CloneProgress, type CloneProgressFields } from "../CloneProgress";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneProgress: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type CloneProgressView } from "../../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "../CloneProgress";
|
||||
|
||||
const base: CloneProgressView = {
|
||||
const base: CloneProgressFields = {
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 2,
|
||||
waiting: 1,
|
||||
limit: 2,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
@@ -30,50 +21,44 @@ const base: CloneProgressView = {
|
||||
};
|
||||
|
||||
describe("CloneProgress", () => {
|
||||
// Braces: a function returned from beforeEach is run as its teardown.
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockReset();
|
||||
it("shows the clone's place in the queue while it waits", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "queued", position: 2 }} />);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #2"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the clone's place in the queue while it waits", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
state: "queued",
|
||||
position: 1,
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
|
||||
it("adds the machine-wide counts when the view carries them", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{ ...base, state: "queued", position: 1, waiting: 1, running: 2, limit: 2 }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows git's phase, percent and speed with a bar while it runs", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
const line = await screen.findByTestId("clone-progress");
|
||||
it("shows git's phase, percent and speed with a bar while it runs", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByTestId("clone-progress");
|
||||
expect(line).toHaveTextContent("Downloading 45%");
|
||||
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
});
|
||||
|
||||
it("shows nothing while the server does not know the token yet", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
|
||||
);
|
||||
render(<CloneProgress token="t" />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
it("shows nothing once the clone finished", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "done" }} />);
|
||||
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("makes tokens the server accepts", () => {
|
||||
const a = newCloneToken();
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
|
||||
expect(newCloneToken()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,23 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||
// A finished clone (an older server's answer, or a job the store saw end)
|
||||
// makes the form read the folder list again.
|
||||
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
// Unknown identity: nothing is checked up front.
|
||||
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||
});
|
||||
@@ -49,7 +55,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
@@ -60,13 +66,52 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
const job = {
|
||||
id: "s1",
|
||||
kind: "source",
|
||||
state: "queued",
|
||||
url: "https://h/a.git",
|
||||
name: "a",
|
||||
parent: "/",
|
||||
target: "/a",
|
||||
laneId: 3,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://h/a.git", parent: "/", name: "a" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// Another workspace's clone is not ours.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("asks for partial clones when the box is ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
@@ -76,7 +121,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://h/big.git",
|
||||
partial: true,
|
||||
})
|
||||
@@ -112,9 +157,11 @@ describe("CloneSourcesForm", () => {
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
url: "https://h.test/a.git",
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
});
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,26 @@
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||
* batch over 500 MB before sending anything, restores from the user's own
|
||||
* batch over 500 MB before sending anything, leaves a repo's .git out of a
|
||||
* folder upload with one notice, restores from the user's own
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home).
|
||||
* when the viewer may not open that home); opens an HTML file as the page
|
||||
* instead of downloading it; New → Clone repository starts a background
|
||||
* clone (`background: true`), keeps a pre-start refusal in the form, and a
|
||||
* finished clone (the `agenthub:clone-job` event) re-reads the folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { api, type CloneJob, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
@@ -30,7 +35,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneProgress: vi.fn(),
|
||||
cloneJobs: vi.fn(async () => ({ jobs: [] })),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -73,9 +78,30 @@ const src: FsListing = {
|
||||
truncated: false,
|
||||
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
|
||||
};
|
||||
const job: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@host:o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/w/one",
|
||||
target: "/w/one/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId: 9, path: "/w/one/repo" },
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@host:o/repo.git", parent: "/w/one", name: "repo" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
@@ -136,6 +162,38 @@ describe("FileExplorer", () => {
|
||||
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
|
||||
});
|
||||
|
||||
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
|
||||
vi.mocked(api.files.list).mockResolvedValue({
|
||||
...root,
|
||||
entries: [
|
||||
...root.entries,
|
||||
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
|
||||
],
|
||||
});
|
||||
const body: Record<string, string> = {
|
||||
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
|
||||
"/dl/1/s.css": "p{color:red}",
|
||||
};
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
open();
|
||||
await screen.findByText("deck.html");
|
||||
fireEvent.doubleClick(row("deck.html"));
|
||||
const viewer = await screen.findByTestId("fx-html-viewer");
|
||||
await waitFor(() =>
|
||||
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
|
||||
"<style>p{color:red}</style>"
|
||||
)
|
||||
);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
|
||||
fetchSpy.mockRestore();
|
||||
click.mockRestore();
|
||||
});
|
||||
|
||||
it("makes a folder inline", async () => {
|
||||
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
|
||||
const user = userEvent.setup();
|
||||
@@ -196,7 +254,8 @@ describe("FileExplorer", () => {
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -217,7 +276,8 @@ describe("FileExplorer", () => {
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
@@ -236,6 +296,23 @@ describe("FileExplorer", () => {
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const mk = (rel: string) => {
|
||||
const f = new File(["hi"], rel.split("/").pop()!);
|
||||
Object.defineProperty(f, "webkitRelativePath", { value: rel });
|
||||
return f;
|
||||
};
|
||||
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
@@ -408,6 +485,33 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a workspace whose folder was deleted out of the places", async () => {
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", folder_missing: true },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
|
||||
await screen.findByText("one", { selector: "aside span" });
|
||||
expect(screen.queryByRole("button", { name: "two" })).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the last folder's entries when the next one cannot be read", async () => {
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => {
|
||||
if (p === "src") throw new Error("ENOENT: no such file or directory");
|
||||
return root;
|
||||
});
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.doubleClick(row("src"));
|
||||
await screen.findByText(/ENOENT/);
|
||||
expect(screen.queryByText("a.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -535,12 +639,8 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
return screen.getByTestId("fx-clone-dialog");
|
||||
};
|
||||
|
||||
it("clones into the open folder of the workspace and says where", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||
created: true,
|
||||
path: "/w/one/src/repo",
|
||||
} as never);
|
||||
it("clones into the open folder of the workspace in the background", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job, state: "queued" } } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
fireEvent.doubleClick(await screen.findByText("src"));
|
||||
@@ -554,26 +654,28 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
background: true,
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
// Accepted: the dialog steps aside, the app-wide dock takes over.
|
||||
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 1,
|
||||
waiting: 0,
|
||||
limit: 2,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 45,
|
||||
detail: "450/1000",
|
||||
it("re-reads the folder and workspaces when a background clone ends", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const lists = vi.mocked(api.files.list).mock.calls.length;
|
||||
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
|
||||
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
|
||||
});
|
||||
|
||||
it("treats an older server's synchronous answer as a finished clone", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -581,43 +683,8 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
const chip = await screen.findByTestId("fx-clone-chip");
|
||||
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||
await user.click(chip);
|
||||
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
finish({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await user.keyboard("{Escape}");
|
||||
fail(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
const again = await openClone(user);
|
||||
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||
"https://git.example/o/r.git"
|
||||
);
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
@@ -625,18 +692,16 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||
created: true,
|
||||
path: "/w/one/r",
|
||||
} as never);
|
||||
.mockResolvedValueOnce({ job } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
// A refusal before the clone starts stays in the form, with a login row.
|
||||
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
@@ -644,6 +709,7 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: "/w/one",
|
||||
background: true,
|
||||
credentials: { username: "ann", password: "tok", remember: true },
|
||||
})
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import { emitGitChanged } from "../../../lib/gitChanged";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
@@ -84,6 +85,19 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitDownloadUrl: (id: number, path: string) =>
|
||||
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
|
||||
gitUpload: vi.fn(),
|
||||
gitHistory: {
|
||||
commit: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
fileHistory: vi.fn(),
|
||||
blameAt: vi.fn(),
|
||||
treeAt: vi.fn(),
|
||||
compare: vi.fn(),
|
||||
compareFile: vi.fn(),
|
||||
pickPlan: vi.fn(),
|
||||
sequence: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
act: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -153,6 +167,44 @@ function discardOf(path: string) {
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = true;
|
||||
const h = api.lanes.gitHistory;
|
||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
||||
vi.mocked(h.pick).mockReset();
|
||||
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
commits: shas.map((sha) => ({
|
||||
sha,
|
||||
short: sha.slice(0, 7),
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
})),
|
||||
branch: "feat/thing",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(h.fileHistory)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
hasMore: false,
|
||||
commits: [
|
||||
{
|
||||
sha: "a".repeat(40),
|
||||
short: "aaaaaaa",
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
parents: [],
|
||||
status: "M",
|
||||
path: "server/lib/git-ops.js",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -382,7 +434,8 @@ describe("LaneChangesPanel", () => {
|
||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||
name: "Tick every file in server/lib",
|
||||
});
|
||||
expect(box).toBeChecked();
|
||||
// The default ticks land in an effect after the list renders.
|
||||
await waitFor(() => expect(box).toBeChecked());
|
||||
await user.click(box);
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(folder);
|
||||
@@ -444,6 +497,84 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
|
||||
// The repo as the server sees it: which branch, which changes.
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
vi.mocked(api.lanes.changes).mockImplementation(async () =>
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
|
||||
if (action === "switch") branch = (body as { branch: string }).branch;
|
||||
return { action, ok: true, output: "", shelves: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
await screen.findByTitle("only-on-thing.ts");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
const before = vi.mocked(api.lanes.changes).mock.calls.length;
|
||||
branch = "other";
|
||||
act(() => emitGitChanged(7, "switch"));
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
|
||||
// Another workspace's write is not this panel's business.
|
||||
const now = vi.mocked(api.lanes.git).mock.calls.length;
|
||||
act(() => emitGitChanged(8, "switch"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
|
||||
});
|
||||
|
||||
it("re-reads history too when Refresh is pressed there", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -543,6 +674,71 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "deck.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["<p>deck</p>"],
|
||||
sha: "c".repeat(40),
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("deck.html"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
const frame = await within(modal).findByTestId("html-frame");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
|
||||
// The source stayed where it was, under the modal.
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
|
||||
],
|
||||
})
|
||||
);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await user.click(await screen.findByTitle("deck.html"));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
await user.click(await screen.findByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
@@ -771,22 +967,37 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
// The confirm button carries its count, and a tracked-only selection
|
||||
// says nothing about new files to delete.
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
confirmLabel: "Discard (1)",
|
||||
message: expect.not.stringMatching(/new file|\{\{/),
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Nothing ticked: nothing to discard.
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
@@ -881,10 +1092,11 @@ describe("LaneChangesPanel", () => {
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 50,
|
||||
file: "server/lib/git-ops.js",
|
||||
})
|
||||
expect(api.lanes.gitHistory.fileHistory).toHaveBeenLastCalledWith(
|
||||
7,
|
||||
"server/lib/git-ops.js",
|
||||
{ limit: 100 }
|
||||
)
|
||||
);
|
||||
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
|
||||
"server/lib/git-ops.js"
|
||||
@@ -1011,7 +1223,7 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
await user.dblClick(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
@@ -1069,8 +1281,21 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "revert",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
from: "b".repeat(40),
|
||||
to: "c".repeat(40),
|
||||
commits: [{ sha: "c".repeat(40), short: "ccccccc", subject: 'Revert "x"' }],
|
||||
});
|
||||
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(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "revert", ["a".repeat(40)])
|
||||
);
|
||||
|
||||
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
|
||||
await waitFor(() =>
|
||||
@@ -1107,10 +1332,22 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 100, ref: "other" })
|
||||
);
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
});
|
||||
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) });
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
|
||||
"a".repeat(40),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before a revert and an amend, and a No sends nothing", async () => {
|
||||
@@ -1126,9 +1363,11 @@ describe("LaneChangesPanel", () => {
|
||||
expect(confirmDialog).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
// A revert previews what it will do; Cancel sends nothing.
|
||||
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
|
||||
await screen.findByTestId("pick-plan");
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1607,6 +1846,25 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
await user.click(
|
||||
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: [],
|
||||
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||
context: 3,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
|
||||
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
|
||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — a row the server cut down", () => {
|
||||
const CUT: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("says how much of the row it hides, in both layouts", () => {
|
||||
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
unmount();
|
||||
render(<LaneDiffView file={CUT} layout="split" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* Changes panel's staged-only box owns that), that a dirty tree's switch asks
|
||||
* whether to bring the changes or shelve them (and offers the shelf when git
|
||||
* refuses to carry them), Create & switch, the offer to restore a branch's
|
||||
* shelf after switching to it, the confirm-and-resend of EAGENTRUNNING, that branches only the remote has are offered (and the list
|
||||
* is re-read after a fetch), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -201,16 +204,114 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
it("loads the branch list on demand and asks how to take the changes while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
// Picking is harmless; applying is what a dirty tree blocks.
|
||||
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
// Nothing is sent before the user picks what happens to the changes.
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
const choice = screen.getByTestId("lane-git-switch-choice");
|
||||
expect(choice).toHaveTextContent("3 uncommitted file(s)");
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "carry",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Shelve and switch when git refuses to carry the changes", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("would be overwritten"), {
|
||||
code: "ECARRYCONFLICT",
|
||||
files: ["src/a-very-long-path/that/should/truncate.ts"],
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
||||
"src/a-very-long-path/that/should/truncate.ts"
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Bring my changes" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "shelve",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before moving files under a working agent, then resends with confirm", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("agent"), { code: "EAGENTRUNNING" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "An agent is working here" })
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "shelve",
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("offers to restore the shelf of the branch switched to, and restores it on Yes", async () => {
|
||||
const shelf = {
|
||||
ref: "stash@{0}",
|
||||
sha: "abc1234def",
|
||||
branch: "development",
|
||||
note: "",
|
||||
createdAt: "2026-11-02T00:00:00.000Z",
|
||||
files: [{ path: "a.txt", status: "M" }],
|
||||
};
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockResolvedValueOnce({ action: "switch", ok: true, output: "", shelves: [shelf] })
|
||||
.mockResolvedValueOnce({ action: "unshelve", ok: true, output: "", conflicts: [] });
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
||||
);
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Restore shelved changes?" })
|
||||
);
|
||||
});
|
||||
|
||||
it("creates and switches in one step — the default on Enter while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText("New branch name…"), "fix/ac-515{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "fix/ac-515",
|
||||
switch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("switches branch on a clean tree", async () => {
|
||||
@@ -236,6 +337,43 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
remotes: ["origin/feat/new"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
expect(
|
||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
||||
).toBeInTheDocument();
|
||||
await user.selectOptions(select, "origin/feat/new");
|
||||
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" })
|
||||
);
|
||||
|
||||
vi.mocked(api.lanes.gitBranches).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @file LaneShelves.test.tsx
|
||||
* @description Pins the Changes panel's Shelves list: hidden while there are
|
||||
* none, one collapsed line otherwise, each shelf's branch, note and files,
|
||||
* Restore sending `unshelve` by sha (and the conflict outcome told in a
|
||||
* toast), Delete needing a second click before `shelf-drop` with
|
||||
* `confirm: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneShelves } from "../LaneShelves";
|
||||
import { api } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } },
|
||||
}));
|
||||
|
||||
const shelf = {
|
||||
ref: "stash@{0}",
|
||||
sha: "abc1234def5678",
|
||||
branch: "fix/ac-515-a-rather-long-branch-name",
|
||||
note: "half way through the login fix",
|
||||
createdAt: "2026-11-02T03:04:05.000Z",
|
||||
files: [
|
||||
{ path: "src/login.ts", status: "M" },
|
||||
{ path: "src/new.ts", status: "??" },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitShelves)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
shelves: [shelf],
|
||||
});
|
||||
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
||||
action: "unshelve",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneShelves", () => {
|
||||
it("renders nothing while there are no shelves", async () => {
|
||||
vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] });
|
||||
const { container } = render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("lists a shelf with its branch, note and files", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ }));
|
||||
const row = screen.getByTestId("lane-shelf");
|
||||
expect(row).toHaveTextContent(shelf.branch);
|
||||
expect(row).toHaveTextContent(shelf.note);
|
||||
await user.click(screen.getByRole("button", { name: "2 file(s)" }));
|
||||
expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts");
|
||||
});
|
||||
|
||||
it("restores by sha and reports a conflicted restore", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({
|
||||
action: "unshelve",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: ["src/login.ts"],
|
||||
kept: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={onChanged} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha })
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file"));
|
||||
});
|
||||
|
||||
it("deletes only on the second click, with confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Delete for good?" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", {
|
||||
sha: shelf.sha,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file Blame / annotate at any revision (JetBrains' Annotate, GitLens'
|
||||
* blame): the file's lines with, on the first line of each run, the commit
|
||||
* that last changed it — short sha, author, relative date, summary. A commit
|
||||
* opens its whole change; "Blame before this change" re-reads the file at
|
||||
* that commit's parent (`previous`). Rows are windowed, so a 20 000-line
|
||||
* file scrolls as fast as a short one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { BlameAt } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { GitModal } from "./GitModal";
|
||||
|
||||
const ROW = 18;
|
||||
const OVERSCAN = 40;
|
||||
|
||||
export function BlameView({
|
||||
target,
|
||||
path,
|
||||
rev,
|
||||
onClose,
|
||||
onOpenCommit,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
/** A commit, or `work` for the file on disk. */
|
||||
rev: string;
|
||||
onClose: () => void;
|
||||
onOpenCommit: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [at, setAt] = useState({ path, rev });
|
||||
const [blame, setBlame] = useState<BlameAt | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setBlame(null);
|
||||
setError(null);
|
||||
void api.lanes.gitHistory
|
||||
.blameAt(target, at.path, at.rev)
|
||||
.then((r) => live && (r.available ? setBlame(r) : setError(t("noBlame"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
|
||||
}, [at.path, at.rev]);
|
||||
|
||||
// Shade alternate commit runs so a run reads as one block.
|
||||
const runs = useMemo(() => {
|
||||
const starts: boolean[] = [];
|
||||
const band: number[] = [];
|
||||
let b = 0;
|
||||
blame?.lines.forEach((l, i) => {
|
||||
const start = i === 0 || blame.lines[i - 1]!.sha !== l.sha;
|
||||
if (start && i) b++;
|
||||
starts.push(start);
|
||||
band.push(b);
|
||||
});
|
||||
return { starts, band };
|
||||
}, [blame]);
|
||||
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [top, setTop] = useState(0);
|
||||
const height = 800;
|
||||
const n = blame?.lines.length ?? 0;
|
||||
const first = Math.max(0, Math.floor(top / ROW) - OVERSCAN);
|
||||
const last = Math.min(n, Math.ceil((top + height) / ROW) + OVERSCAN);
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
tall
|
||||
testId="blame-view"
|
||||
onClose={onClose}
|
||||
title={
|
||||
<span>
|
||||
{t("blameTitle", { path: at.path })}{" "}
|
||||
<span className="font-mono text-fg-muted">
|
||||
@ {at.rev === "work" ? t("workingTree") : at.rev.slice(0, 7)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{error && <p className="text-[12px] text-status-danger [overflow-wrap:anywhere]">{error}</p>}
|
||||
{!blame && !error && (
|
||||
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
)}
|
||||
{blame && (
|
||||
<div
|
||||
ref={box}
|
||||
onScroll={(e) => setTop(e.currentTarget.scrollTop)}
|
||||
className="min-h-0 flex-1 overflow-auto font-mono text-[11px] leading-[18px]"
|
||||
>
|
||||
<div style={{ height: n * ROW, position: "relative", minWidth: "100%" }}>
|
||||
{blame.lines.slice(first, last).map((l, k) => {
|
||||
const i = first + k;
|
||||
const c = l.sha ? blame.commits[l.sha] : null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="blame-line"
|
||||
style={{ position: "absolute", top: i * ROW, height: ROW, left: 0, right: 0 }}
|
||||
className={`flex ${runs.band[i]! % 2 ? "bg-surface-2/60" : ""}`}
|
||||
>
|
||||
<span className="flex w-[16rem] shrink-0 items-center gap-1 overflow-hidden border-r border-border-light pr-1 text-fg-muted">
|
||||
{runs.starts[i] &&
|
||||
(c && l.sha ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCommit(l.sha!)}
|
||||
title={`${c.summary ?? ""}\n${c.author ?? ""} · ${c.date ? new Date(c.date).toLocaleString() : ""}`}
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
>
|
||||
{c.short}
|
||||
</button>
|
||||
<span className="min-w-0 truncate font-sans" title={c.author}>
|
||||
{c.author}
|
||||
</span>
|
||||
<span className="shrink-0 font-sans">
|
||||
{c.date ? timeAgo(c.date, true) : ""}
|
||||
</span>
|
||||
{c.previous && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("blameBefore")}
|
||||
aria-label={t("blameBefore")}
|
||||
onClick={() => setAt({ path: at.path, rev: c.previous! })}
|
||||
className="shrink-0 font-sans hover:text-fg-primary"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-sans italic">{t("notCommitted")}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="w-10 shrink-0 select-none pr-1 text-right text-fg-muted">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre pl-1 text-fg-primary">{l.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* @file The git bar's Branches panel (JetBrains' branch popup, GitLens'
|
||||
* Branches view): a dialog over the workspace listing every local branch
|
||||
* (current marked, upstream with ahead/behind, last commit, why a delete would
|
||||
* be refused) and the fetched remote branches grouped by remote, filtered by a
|
||||
* search box and walked with the arrow keys; picking a row opens its actions —
|
||||
* new branch from it (optionally switching to it on a clean tree), rename,
|
||||
* set/unset upstream, merge into the current branch, delete. Its Tags tab is
|
||||
* `TagManager`. Everything it may do is decided by the server
|
||||
* (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled
|
||||
* button here is a courtesy, and git's own text is shown when it refuses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
Link2,
|
||||
Link2Off,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
gitTarget,
|
||||
type GitLocalBranch,
|
||||
type GitRefs,
|
||||
type GitRemoteBranch,
|
||||
type GitSource,
|
||||
} from "../../../lib/api";
|
||||
import { useDialog } from "../../../hooks/useDialog";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { isSubmitEnter } from "../../../lib/keys";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { TagManager } from "./TagManager";
|
||||
import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi";
|
||||
|
||||
type Mode = "new" | "rename" | "upstream" | null;
|
||||
|
||||
export function BranchManager({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
dirty,
|
||||
merging,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: GitSource;
|
||||
/** Uncommitted files: a "switch to it" is refused on a dirty tree. */
|
||||
dirty: number;
|
||||
/** A merge, cherry-pick or revert is open: every write here is refused. */
|
||||
merging: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after every write, so the git bar and the Changes panel re-read. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitRefs"]);
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
useDialog(true, panelRef, onClose, searchRef);
|
||||
|
||||
const target = gitTarget(laneId, session, source);
|
||||
const [refs, setRefs] = useState<GitRefs | null>(null);
|
||||
const [tab, setTab] = useState<"branches" | "tags">("branches");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<Mode>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [switchTo, setSwitchTo] = useState(false);
|
||||
const [upstream, setUpstream] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setRefs(await api.lanes.gitRefs(target));
|
||||
} catch {
|
||||
setRefs({
|
||||
available: false,
|
||||
current: null,
|
||||
head: null,
|
||||
local: [],
|
||||
remotes: [],
|
||||
tags: [],
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [laneId, session, source]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run: RunRefAction = async (action, body, done) => {
|
||||
if (inFlight.current) return false;
|
||||
inFlight.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitRefAction(target, action, body);
|
||||
if (done) toast.success(done);
|
||||
setMode(null);
|
||||
setText("");
|
||||
await load();
|
||||
onChanged();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
setError(
|
||||
code === "EGITAUTH"
|
||||
? t("errLogin")
|
||||
: code === "ESUBDIR"
|
||||
? t("errSubdir")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const match = (s: string) => !q || s.toLowerCase().includes(q);
|
||||
const local = useMemo(
|
||||
() => (refs?.local || []).filter((b) => match(b.name)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[refs, q]
|
||||
);
|
||||
const remoteGroups = useMemo(
|
||||
() =>
|
||||
(refs?.remotes || [])
|
||||
.map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) }))
|
||||
.filter((g) => g.branches.length),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[refs, q]
|
||||
);
|
||||
const allRemote = useMemo(
|
||||
() => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)),
|
||||
[refs]
|
||||
);
|
||||
const current = refs?.current ?? null;
|
||||
|
||||
const pick = (key: string) => {
|
||||
setSelected((s) => (s === key ? null : key));
|
||||
setMode(null);
|
||||
setText("");
|
||||
setError(null);
|
||||
};
|
||||
const open = (m: Mode, initial = "") => {
|
||||
setMode((v) => (v === m ? null : m));
|
||||
setText(initial);
|
||||
setSwitchTo(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const merge = async (branch: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("merge"),
|
||||
message: t("mergeConfirm", { branch, current: current ?? "HEAD" }),
|
||||
confirmLabel: t("merge"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok) await run("merge", { branch }, t("done.merge", { branch }));
|
||||
};
|
||||
const remove = async (b: GitLocalBranch) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("delete"),
|
||||
message: b.merged
|
||||
? t("deleteConfirm", { branch: b.name })
|
||||
: t("deleteUnmerged", { branch: b.name }),
|
||||
confirmLabel: t("delete"),
|
||||
destructive: true,
|
||||
});
|
||||
if (ok) {
|
||||
if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name })))
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
|
||||
/** Why `git branch -d` would refuse this branch, or null. */
|
||||
const deleteBlock = (b: GitLocalBranch) =>
|
||||
b.current
|
||||
? t("why.current")
|
||||
: b.protected
|
||||
? t("why.protected")
|
||||
: b.worktree
|
||||
? t("why.worktree", { path: b.worktree })
|
||||
: null;
|
||||
|
||||
const newBranchForm = (from: string) => (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && text.trim())
|
||||
void run(
|
||||
"branch-create",
|
||||
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
|
||||
t("done.create", { branch: text.trim() })
|
||||
);
|
||||
}}
|
||||
placeholder={t("newBranchName")}
|
||||
aria-label={t("newBranchName")}
|
||||
className={smallInput}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={switchTo}
|
||||
onChange={setSwitchTo}
|
||||
disabled={dirty > 0 || merging}
|
||||
label={t("switchToIt")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !text.trim()}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"branch-create",
|
||||
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
|
||||
t("done.create", { branch: text.trim() })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("create")}
|
||||
</button>
|
||||
{dirty > 0 && <span className="text-[10px] text-fg-muted">{t("switchDirty")}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const localActions = (b: GitLocalBranch) => {
|
||||
const block = deleteBlock(b);
|
||||
return (
|
||||
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button type="button" className={smallBtn} disabled={busy} onClick={() => open("new")}>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("newFrom")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || b.protected || merging}
|
||||
title={b.protected ? t("why.protected") : t("renameHint")}
|
||||
onClick={() => open("rename", b.name)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || !allRemote.length}
|
||||
title={allRemote.length ? t("upstreamHint") : t("noRemoteBranches")}
|
||||
onClick={() => {
|
||||
open("upstream");
|
||||
setUpstream(b.upstream ?? "");
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{t("setUpstream")}
|
||||
</button>
|
||||
{b.upstream && (
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging}
|
||||
onClick={() =>
|
||||
void run("branch-upstream", { branch: b.name }, t("done.unsetUpstream"))
|
||||
}
|
||||
>
|
||||
<Link2Off className="h-3 w-3" />
|
||||
{t("unsetUpstream")}
|
||||
</button>
|
||||
)}
|
||||
{!b.current && (
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || dirty > 0 || !current}
|
||||
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
|
||||
onClick={() => void merge(b.name)}
|
||||
>
|
||||
<GitMerge className="h-3 w-3" />
|
||||
{t("mergeInto", { current: current ?? "HEAD" })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${smallBtn} hover:text-status-danger`}
|
||||
disabled={busy || !!block}
|
||||
title={block ?? (b.merged ? t("deleteHint") : t("why.unmerged"))}
|
||||
onClick={() => void remove(b)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("delete")}
|
||||
</button>
|
||||
</div>
|
||||
{block && <p className="text-[10px] text-fg-muted [overflow-wrap:anywhere]">{block}</p>}
|
||||
{!block && !b.merged && (
|
||||
<p className="text-[10px] text-status-warning">{t("why.unmerged")}</p>
|
||||
)}
|
||||
{mode === "new" && newBranchForm(b.name)}
|
||||
{mode === "rename" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name)
|
||||
void run(
|
||||
"branch-rename",
|
||||
{ branch: b.name, name: text.trim() },
|
||||
t("done.rename", { branch: text.trim() })
|
||||
);
|
||||
}}
|
||||
aria-label={t("renameTo")}
|
||||
placeholder={t("renameTo")}
|
||||
className={smallInput}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !text.trim() || text.trim() === b.name}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"branch-rename",
|
||||
{ branch: b.name, name: text.trim() },
|
||||
t("done.rename", { branch: text.trim() })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("rename")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mode === "upstream" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={upstream}
|
||||
onChange={(e) => setUpstream(e.target.value)}
|
||||
aria-label={t("setUpstream")}
|
||||
className="select min-w-0 flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">{t("pickUpstream")}</option>
|
||||
{allRemote.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !upstream || upstream === b.upstream}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"branch-upstream",
|
||||
{ branch: b.name, upstream },
|
||||
t("done.setUpstream", { upstream })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("apply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const remoteActions = (r: GitRemoteBranch) => (
|
||||
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy}
|
||||
onClick={() => open("new", r.branch)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("newFrom")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || dirty > 0 || !current}
|
||||
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
|
||||
onClick={() => void merge(r.name)}
|
||||
>
|
||||
<GitMerge className="h-3 w-3" />
|
||||
{t("mergeInto", { current: current ?? "HEAD" })}
|
||||
</button>
|
||||
</div>
|
||||
{mode === "new" && newBranchForm(r.name)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const row = (
|
||||
key: string,
|
||||
name: string,
|
||||
sub: React.ReactNode,
|
||||
badges: React.ReactNode,
|
||||
actions: () => React.ReactNode
|
||||
) => (
|
||||
<li key={key} className={selected === key ? "rounded bg-surface-2" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
data-ref-row
|
||||
aria-expanded={selected === key}
|
||||
onClick={() => pick(key)}
|
||||
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||
>
|
||||
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span title={name} className="truncate font-mono text-xs text-fg-primary">
|
||||
{name}
|
||||
</span>
|
||||
{badges}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-fg-muted">{sub}</span>
|
||||
</span>
|
||||
</button>
|
||||
{selected === key && actions()}
|
||||
</li>
|
||||
);
|
||||
|
||||
const badge = (label: string, tone = "text-fg-muted border-border-light") => (
|
||||
<span className={`shrink-0 rounded border px-1 text-[9px] uppercase ${tone}`}>{label}</span>
|
||||
);
|
||||
|
||||
const localRow = (b: GitLocalBranch) =>
|
||||
row(
|
||||
`l:${b.name}`,
|
||||
b.name,
|
||||
<>
|
||||
{b.subject}
|
||||
{b.date ? ` · ${timeAgo(b.date)}` : ""}
|
||||
</>,
|
||||
<>
|
||||
{b.current && badge(t("current"), "text-accent border-accent/40")}
|
||||
{b.protected && badge(t("protected"))}
|
||||
{b.worktree && (
|
||||
<span title={b.worktree} className="shrink-0">
|
||||
{badge(t("worktree"))}
|
||||
</span>
|
||||
)}
|
||||
{b.upstream && (
|
||||
<span
|
||||
title={b.upstream}
|
||||
className="flex shrink-0 items-center gap-0.5 font-mono text-[10px] text-fg-muted"
|
||||
>
|
||||
{b.gone ? (
|
||||
<span className="text-status-warning">{t("gone")}</span>
|
||||
) : (
|
||||
<>
|
||||
{(b.ahead ?? 0) > 0 && (
|
||||
<span className="flex items-center text-status-warning">
|
||||
<ArrowUp className="h-3 w-3" aria-label={t("ahead")} />
|
||||
{b.ahead}
|
||||
</span>
|
||||
)}
|
||||
{(b.behind ?? 0) > 0 && (
|
||||
<span className="flex items-center text-accent">
|
||||
<ArrowDown className="h-3 w-3" aria-label={t("behind")} />
|
||||
{b.behind}
|
||||
</span>
|
||||
)}
|
||||
{!b.ahead && !b.behind && <Check className="h-3 w-3" aria-label={t("inSync")} />}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>,
|
||||
() => localActions(b)
|
||||
);
|
||||
|
||||
const remoteRow = (r: GitRemoteBranch) =>
|
||||
row(
|
||||
`r:${r.name}`,
|
||||
r.name,
|
||||
<>
|
||||
{r.subject}
|
||||
{r.date ? ` · ${timeAgo(r.date)}` : ""}
|
||||
</>,
|
||||
null,
|
||||
() => remoteActions(r)
|
||||
);
|
||||
|
||||
const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
setSelected(null);
|
||||
setMode(null);
|
||||
setError(null);
|
||||
}}
|
||||
className={`flex items-center gap-1 border-b-2 px-2 py-1 text-xs ${
|
||||
tab === id
|
||||
? "border-accent text-fg-primary"
|
||||
: "border-transparent text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[55] flex items-start justify-center bg-black/60 p-4 sm:pt-16"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid="branch-manager"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border-light px-3 pt-2">
|
||||
<h3 id={titleId} className="sr-only">
|
||||
{t("title")}
|
||||
</h3>
|
||||
<div role="tablist" className="flex min-w-0 flex-1 gap-1">
|
||||
{tabBtn("branches", t("branches"), <GitBranch className="h-3.5 w-3.5" />)}
|
||||
{tabBtn("tags", t("tags"), <Tag className="h-3.5 w-3.5" />)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon -mt-1"
|
||||
aria-label={t("close")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
panelRef.current?.querySelector<HTMLElement>("[data-ref-row]")?.focus();
|
||||
}
|
||||
}}
|
||||
placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")}
|
||||
aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")}
|
||||
className={`${smallInput} !text-xs`}
|
||||
/>
|
||||
</div>
|
||||
{merging && <p className="px-3 pb-1 text-[11px] text-status-warning">{t("merging")}</p>}
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
data-testid="branch-manager-error"
|
||||
className="mx-3 mb-2 max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3" onKeyDown={onListKeys}>
|
||||
{!refs ? (
|
||||
<p className="px-2 py-4 text-xs text-fg-muted">{t("loading")}</p>
|
||||
) : !refs.available ? (
|
||||
<p className="px-2 py-4 text-xs text-fg-muted">{t("unavailable")}</p>
|
||||
) : tab === "tags" ? (
|
||||
<TagManager
|
||||
tags={(refs.tags || []).filter((x) => match(x.name))}
|
||||
busy={busy}
|
||||
merging={merging}
|
||||
dirty={dirty}
|
||||
run={run}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("local")}
|
||||
</h4>
|
||||
{local.length ? (
|
||||
<ul>{local.map(localRow)}</ul>
|
||||
) : (
|
||||
<p className="px-2 text-xs text-fg-muted">{t("noMatch")}</p>
|
||||
)}
|
||||
{remoteGroups.map((g) => (
|
||||
<section key={g.remote}>
|
||||
<h4
|
||||
title={g.remote}
|
||||
className="truncate px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-fg-muted"
|
||||
>
|
||||
{t("remote", { remote: g.remote })}
|
||||
</h4>
|
||||
<ul>{g.branches.map(remoteRow)}</ul>
|
||||
</section>
|
||||
))}
|
||||
{refs.truncated && (
|
||||
<p className="px-2 pt-2 text-[10px] text-fg-muted">{t("truncated")}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BranchManager;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file The git bar's "Branches & tags" icon button, which owns the Branches
|
||||
* panel (`BranchManager`) it opens — portalled to `document.body`, so no
|
||||
* scrolling or transformed ancestor of the Changes panel can clip it. The
|
||||
* panel closes when the bar starts showing another workspace.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tags } from "lucide-react";
|
||||
import type { GitSource } from "../../../lib/api";
|
||||
import { BranchManager } from "./BranchManager";
|
||||
|
||||
export function BranchesButton({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
dirty,
|
||||
merging,
|
||||
disabled,
|
||||
onChanged,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: GitSource;
|
||||
dirty: number;
|
||||
merging: boolean;
|
||||
disabled?: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitRefs"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [shown, setShown] = useState(`${laneId}|${session ?? ""}|${source ?? ""}`);
|
||||
const key = `${laneId}|${session ?? ""}|${source ?? ""}`;
|
||||
if (shown !== key) {
|
||||
setShown(key);
|
||||
setOpen(false);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
title={t("openPanelHint")}
|
||||
aria-label={t("openPanel")}
|
||||
aria-haspopup="dialog"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Tags className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<BranchManager
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
merging={merging}
|
||||
onClose={() => setOpen(false)}
|
||||
onChanged={onChanged}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BranchesButton;
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @file Browse files at a revision (GitHub's "Browse files", GitLens' "Open
|
||||
* files at revision"): a read-only tree of the workspace folder as it was at
|
||||
* one commit — folders open on a click, read lazily — and the picked file's
|
||||
* text (or image) beside it, with Blame at that commit. Every read goes
|
||||
* through the server's own guards: a secret file is neither listed nor
|
||||
* readable, a big file is capped, a binary one is named, not shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, File, Folder } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { TreeAtEntry } from "../../../lib/gitHistory";
|
||||
import type { LaneFileContent } from "../../../lib/types";
|
||||
import { LaneFileView } from "../LaneDiffView";
|
||||
import { GitModal } from "./GitModal";
|
||||
import { BlameView } from "./BlameView";
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i;
|
||||
|
||||
export function BrowseAtRevision({
|
||||
target,
|
||||
sha,
|
||||
short,
|
||||
onClose,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
short: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
// Folder path → its entries; "" is the workspace folder.
|
||||
const [tree, setTree] = useState<Record<string, TreeAtEntry[]>>({});
|
||||
const [open, setOpen] = useState<Set<string>>(new Set([""]));
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<LaneFileContent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blame, setBlame] = useState(false);
|
||||
|
||||
const readDir = (dir: string) => {
|
||||
if (tree[dir]) return;
|
||||
void api.lanes.gitHistory
|
||||
.treeAt(target, sha, dir)
|
||||
.then((r) => r.available && setTree((m) => ({ ...m, [dir]: r.entries })))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
useEffect(() => {
|
||||
readDir("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per commit
|
||||
}, [sha]);
|
||||
|
||||
const openFile = (p: string) => {
|
||||
setFile(p);
|
||||
setContent(null);
|
||||
setError(null);
|
||||
if (IMAGE.test(p)) return;
|
||||
void api.lanes
|
||||
.gitFile(target, p, sha)
|
||||
.then(setContent)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
const toggleDir = (p: string) => {
|
||||
setOpen((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
return next;
|
||||
});
|
||||
readDir(p);
|
||||
};
|
||||
|
||||
const rows = (dir: string, depth: number): ReactNode =>
|
||||
(tree[dir] ?? []).map((e) => (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (e.dir ? toggleDir(e.path) : e.submodule ? undefined : openFile(e.path))}
|
||||
title={e.path}
|
||||
style={{ paddingLeft: depth * 12 + 4 }}
|
||||
className={`flex w-full items-center gap-1 rounded py-0.5 pr-1 text-left text-[11px] hover:bg-surface-2 ${
|
||||
file === e.path ? "bg-surface-3 text-fg-primary" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{e.dir ? (
|
||||
open.has(e.path) ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
{e.dir ? (
|
||||
<Folder className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
) : (
|
||||
<File className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{e.name}</span>
|
||||
</button>
|
||||
{e.dir && open.has(e.path) && <ul>{rows(e.path, depth + 1)}</ul>}
|
||||
</li>
|
||||
));
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
tall
|
||||
testId="browse-at-revision"
|
||||
onClose={onClose}
|
||||
title={t("browseTitle", { short })}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 sm:flex-row">
|
||||
<ul
|
||||
data-testid="browse-tree"
|
||||
className="max-h-[35vh] shrink-0 overflow-auto rounded border border-border-light p-1 sm:max-h-none sm:w-64"
|
||||
>
|
||||
{tree[""] ? (
|
||||
rows("", 0)
|
||||
) : (
|
||||
<li className="text-[11px] text-fg-muted">{t("lanes:changes.loading")}</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{file && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span className="min-w-0 flex-1 font-mono text-fg-primary [overflow-wrap:anywhere]">
|
||||
{file}
|
||||
</span>
|
||||
{!IMAGE.test(file) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlame(true)}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("blame")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!file && !error && <p className="text-[12px] text-fg-muted">{t("pickAFile")}</p>}
|
||||
{file && IMAGE.test(file) && (
|
||||
<img
|
||||
src={api.lanes.gitRawUrl(target, file, sha)}
|
||||
alt={file}
|
||||
className="max-h-full max-w-full self-start object-contain"
|
||||
/>
|
||||
)}
|
||||
{file && content && content.available && content.binary && (
|
||||
<p className="text-[12px] text-fg-muted">{t("binaryFile")}</p>
|
||||
)}
|
||||
{file && content && content.available && !content.binary && (
|
||||
<LaneFileView path={file} lines={content.lines} truncated={content.truncated} fill />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{blame && file && (
|
||||
<BlameView
|
||||
target={target}
|
||||
path={file}
|
||||
rev={sha}
|
||||
onClose={() => setBlame(false)}
|
||||
onOpenCommit={() => setBlame(false)}
|
||||
/>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @file The confirmation a cherry-pick or revert opens with, showing exactly
|
||||
* what will happen before anything does (the reference tools' preview): the
|
||||
* commits in the order git will apply them (numbered — read from the server,
|
||||
* which orders them the same way the action will), the branch they land on,
|
||||
* whether the working tree is clean, and what happens on a conflict. Confirm
|
||||
* is disabled, with the reason, when the server would refuse (a dirty tree, a
|
||||
* subfolder workspace, another operation open, a merge commit).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { PickPlan, PickResult } from "../../../lib/gitHistory";
|
||||
import { GitModal, ModalButton } from "./GitModal";
|
||||
|
||||
export function CherryPickDialog({
|
||||
target,
|
||||
op,
|
||||
shas,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
op: "cherry-pick" | "revert";
|
||||
shas: string[];
|
||||
onClose: () => void;
|
||||
onDone: (r: PickResult) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [plan, setPlan] = useState<PickPlan | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.pickPlan(target, op, shas)
|
||||
.then((r) => live && (r.available ? setPlan(r) : setError(t("planUnavailable"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one plan per dialog
|
||||
}, []);
|
||||
|
||||
const branch = plan?.branch ?? "HEAD";
|
||||
const blocker = !plan
|
||||
? null
|
||||
: plan.busy
|
||||
? t("blockBusy")
|
||||
: !plan.repoRoot
|
||||
? t("blockSubdir")
|
||||
: !plan.clean
|
||||
? t("blockDirty")
|
||||
: null;
|
||||
const n = plan?.commits.length ?? shas.length;
|
||||
const title =
|
||||
op === "revert" ? t("revertTitle", { count: n }) : t("pickTitle", { count: n, branch });
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDone(
|
||||
await api.lanes.gitHistory.pick(
|
||||
target,
|
||||
op,
|
||||
plan!.commits.map((c) => c.sha)
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
testId="pick-dialog"
|
||||
footer={
|
||||
<>
|
||||
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
|
||||
<ModalButton
|
||||
primary
|
||||
testId="pick-confirm"
|
||||
disabled={!plan || !!blocker || running}
|
||||
title={blocker ?? undefined}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{running
|
||||
? t("running")
|
||||
: op === "revert"
|
||||
? t("revertN", { count: n })
|
||||
: t("pickN", { count: n, branch })}
|
||||
</ModalButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 text-[12px] text-fg-secondary">
|
||||
{!plan && !error && <p className="text-fg-muted">{t("planning")}</p>}
|
||||
{plan && (
|
||||
<>
|
||||
<p className="[overflow-wrap:anywhere]">
|
||||
{op === "revert"
|
||||
? t("revertIntro", { count: n, branch })
|
||||
: t("pickIntro", { count: n, branch })}
|
||||
</p>
|
||||
<ol
|
||||
data-testid="pick-plan"
|
||||
className="space-y-0.5 rounded border border-border-light bg-surface-0 p-1.5"
|
||||
>
|
||||
{plan.commits.map((c, i) => (
|
||||
<li key={c.sha} className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span className="w-5 shrink-0 text-right text-fg-muted">{i + 1}.</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="flex items-center gap-1.5">
|
||||
{plan.clean && plan.repoRoot && !plan.busy ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-status-success" aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span data-testid="pick-tree-state" className="[overflow-wrap:anywhere]">
|
||||
{blocker ?? t("treeClean")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-fg-muted">{t(op === "revert" ? "revertResult" : "pickResult")}</p>
|
||||
<p className="text-fg-muted">{t("onConflict", { op: t(`op.${op}`) })}</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @file The History tab's commit details pane (GitKraken's / JetBrains'
|
||||
* right-hand commit panel): the full message, author and committer with
|
||||
* dates, parents (each opens that commit), the commit's refs and the branches
|
||||
* and tags that contain it, its changed files with +/- counts (a file opens
|
||||
* that commit's diff of it in the panel's own diff viewer), and Copy SHA,
|
||||
* Copy patch, Browse files, Compare with current and View all changes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { CommitDetails as Details } from "../../../lib/gitHistory";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
import { RefChip, sortBadges } from "./CommitGraph";
|
||||
|
||||
export function CommitDetails({
|
||||
target,
|
||||
sha,
|
||||
onClose,
|
||||
onOpenCommit,
|
||||
onViewChanges,
|
||||
onOpenFile,
|
||||
onBrowse,
|
||||
onCompare,
|
||||
onCopyPatch,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
onClose: () => void;
|
||||
/** A parent clicked: focus that commit. */
|
||||
onOpenCommit: (sha: string) => void;
|
||||
onViewChanges: () => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
onBrowse: () => void;
|
||||
onCompare: () => void;
|
||||
onCopyPatch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [details, setDetails] = useState<Details | null>(null);
|
||||
const [files, setFiles] = useState<LaneChangeFile[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.commit(target, sha)
|
||||
.then((r) => live && (r.available ? setDetails(r) : setError(t("noDetails"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
void api.lanes
|
||||
.changes(target, { range: "commit", sha })
|
||||
.then((r) => live && setFiles(r.available ? r.files : []))
|
||||
.catch(() => live && setFiles([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on sha by the parent
|
||||
}, [sha]);
|
||||
|
||||
const button =
|
||||
"shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary";
|
||||
const added = files?.reduce((n, f) => n + (f.added ?? 0), 0) ?? 0;
|
||||
const deleted = files?.reduce((n, f) => n + (f.deleted ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="commit-details"
|
||||
aria-label={t("detailsTitle")}
|
||||
className="max-h-[45%] min-h-[6rem] shrink-0 overflow-y-auto rounded border border-border-light bg-surface-0 p-2 text-[11px]"
|
||||
>
|
||||
<div className="mb-1 flex items-start gap-1.5">
|
||||
<span className="font-mono text-fg-muted">{sha.slice(0, 10)}</span>
|
||||
<span className="min-w-0 flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("close")}
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-status-danger [overflow-wrap:anywhere]">{error}</p>}
|
||||
{!details && !error && <p className="text-fg-muted">{t("lanes:changes.loading")}</p>}
|
||||
{details && (
|
||||
<>
|
||||
<p
|
||||
data-testid="commit-details-message"
|
||||
className="mb-1.5 whitespace-pre-wrap text-fg-primary [overflow-wrap:anywhere]"
|
||||
>
|
||||
{details.message}
|
||||
</p>
|
||||
<dl className="mb-1.5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-fg-secondary">
|
||||
<dt className="text-fg-muted">{t("author")}</dt>
|
||||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{details.author} <{details.email}> · {new Date(details.date).toLocaleString()}
|
||||
</dd>
|
||||
{(details.committer !== details.author || details.committedAt !== details.date) && (
|
||||
<>
|
||||
<dt className="text-fg-muted">{t("committer")}</dt>
|
||||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{details.committer} · {new Date(details.committedAt).toLocaleString()}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-fg-muted">{t("parents", { count: details.parents.length })}</dt>
|
||||
<dd className="flex min-w-0 flex-wrap gap-1">
|
||||
{details.parents.length === 0 && <span>{t("rootCommit")}</span>}
|
||||
{details.parents.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => onOpenCommit(p)}
|
||||
className="font-mono text-accent hover:underline"
|
||||
>
|
||||
{p.slice(0, 7)}
|
||||
</button>
|
||||
))}
|
||||
</dd>
|
||||
{details.badges.length > 0 && (
|
||||
<>
|
||||
<dt className="text-fg-muted">{t("refs")}</dt>
|
||||
<dd className="flex min-w-0 flex-wrap gap-1">
|
||||
{sortBadges(details.badges).map((b) => (
|
||||
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
|
||||
))}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{(details.branches.length > 0 || details.tags.length > 0) && (
|
||||
<>
|
||||
<dt className="text-fg-muted">{t("containedIn")}</dt>
|
||||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{[...details.branches, ...details.tags.map((x) => `tag ${x}`)].join(", ")}
|
||||
{details.containsTruncated ? " …" : ""}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<button type="button" className={button} onClick={onViewChanges}>
|
||||
{t("viewAllChanges")}
|
||||
</button>
|
||||
<button type="button" className={button} onClick={() => void copyText(details.sha)}>
|
||||
{t("lanes:changes.copySha")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={details.parents.length > 1}
|
||||
onClick={onCopyPatch}
|
||||
>
|
||||
{t("copyPatch")}
|
||||
</button>
|
||||
<button type="button" className={button} onClick={onBrowse}>
|
||||
{t("browseFiles")}
|
||||
</button>
|
||||
<button type="button" className={button} onClick={onCompare}>
|
||||
{t("compareWithCurrent")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{files && (
|
||||
<>
|
||||
<p className="mb-0.5 text-fg-muted">
|
||||
{t("filesChanged", { count: files.length })}{" "}
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</p>
|
||||
<ul data-testid="commit-details-files">
|
||||
{files.map((f) => (
|
||||
<li key={f.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenFile(f.path)}
|
||||
title={f.path}
|
||||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
|
||||
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
|
||||
{f.path}
|
||||
</span>
|
||||
{f.binary ? (
|
||||
<span className="shrink-0 text-fg-muted">{t("binary")}</span>
|
||||
) : (
|
||||
<span className="shrink-0 font-mono">
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file The History tab's commit rows, drawn like VS Code's Source Control
|
||||
* Graph / GitLens: the graph cell (lanes and merge edges from
|
||||
* `lib/commitGraph`), the ref badges (current branch, local and remote
|
||||
* branches, tags, a detached HEAD), the subject, author and relative date.
|
||||
* Colours are theme tokens (CSS variables), so a lane reads in every theme.
|
||||
* Long subjects and names truncate with the whole text in a tooltip.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, GitBranch, Tag, Crosshair } from "lucide-react";
|
||||
import type { GraphRow } from "../../../lib/commitGraph";
|
||||
import type { RefBadge } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
|
||||
/** One fixed-height row per commit so the lanes join up row to row. */
|
||||
export const GRAPH_ROW = 30;
|
||||
const GRAPH_COL = 12;
|
||||
/** Columns drawn at most; a wider graph is clipped, not the row. */
|
||||
const MAX_COLS = 12;
|
||||
/** Theme tokens, cycled per lane. */
|
||||
const GRAPH_COLORS = [
|
||||
"rgb(var(--accent))",
|
||||
"rgb(var(--status-success))",
|
||||
"rgb(var(--status-warning))",
|
||||
"rgb(var(--syn-property))",
|
||||
"rgb(var(--status-danger))",
|
||||
"rgb(var(--syn-function))",
|
||||
"rgb(var(--kept))",
|
||||
"rgb(var(--diff-mod))",
|
||||
];
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
|
||||
export function GraphCell({
|
||||
row,
|
||||
width,
|
||||
merge,
|
||||
head,
|
||||
}: {
|
||||
row: GraphRow;
|
||||
width: number;
|
||||
/** A merge commit: a hollow node. */
|
||||
merge?: boolean;
|
||||
/** HEAD: a ringed node. */
|
||||
head?: boolean;
|
||||
}) {
|
||||
const cols = Math.min(width, MAX_COLS);
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={cols * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0 overflow-hidden"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{head && (
|
||||
<circle
|
||||
cx={x(row.col)}
|
||||
cy={y(0.5)}
|
||||
r={6}
|
||||
fill="none"
|
||||
stroke={hue(row.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={x(row.col)}
|
||||
cy={y(0.5)}
|
||||
r={3.5}
|
||||
fill={merge ? "rgb(var(--surface-1))" : hue(row.color)}
|
||||
stroke={hue(row.color)}
|
||||
strokeWidth={merge ? 1.5 : 0}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** One ref badge: the kind's icon, the name truncated, the full name on hover. */
|
||||
export function RefChip({ badge }: { badge: RefBadge }) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const Icon =
|
||||
badge.kind === "tag"
|
||||
? Tag
|
||||
: badge.kind === "remote"
|
||||
? Cloud
|
||||
: badge.kind === "head"
|
||||
? Crosshair
|
||||
: GitBranch;
|
||||
const tone = badge.current
|
||||
? "border-accent/60 bg-accent/15 text-accent font-semibold"
|
||||
: badge.kind === "tag"
|
||||
? "border-status-warning/50 text-status-warning"
|
||||
: badge.kind === "remote"
|
||||
? "border-border text-fg-muted"
|
||||
: badge.kind === "head"
|
||||
? "border-status-danger/50 text-status-danger"
|
||||
: "border-status-success/50 text-status-success";
|
||||
const label = t(`badge.${badge.kind}`, { name: badge.name });
|
||||
return (
|
||||
<span
|
||||
title={badge.current ? t("badge.current", { name: badge.name }) : label}
|
||||
data-testid="ref-badge"
|
||||
data-kind={badge.kind}
|
||||
className={`inline-flex max-w-[10rem] shrink items-center gap-0.5 rounded border px-1 text-[10px] leading-4 ${tone}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{badge.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Badges in the order the reference tools show them: HEAD's branch first. */
|
||||
export function sortBadges(badges: RefBadge[]): RefBadge[] {
|
||||
const rank = (b: RefBadge) =>
|
||||
b.current ? 0 : b.kind === "head" ? 1 : b.kind === "local" ? 2 : b.kind === "remote" ? 3 : 4;
|
||||
return [...badges].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export interface GraphCommit {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
email?: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
badges?: RefBadge[];
|
||||
}
|
||||
|
||||
/** The text half of a history row (the graph cell is drawn beside it). */
|
||||
export const CommitText = memo(function CommitText({ c }: { c: GraphCommit }) {
|
||||
const badges = sortBadges(c.badges ?? []);
|
||||
const when = new Date(c.date);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
{badges.slice(0, 4).map((b) => (
|
||||
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
|
||||
))}
|
||||
{badges.length > 4 && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] text-fg-muted"
|
||||
title={badges
|
||||
.slice(4)
|
||||
.map((b) => b.name)
|
||||
.join(", ")}
|
||||
>
|
||||
+{badges.length - 4}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5 text-[10px] text-fg-muted">
|
||||
<span className="shrink-0 font-mono">{c.short}</span>
|
||||
<span className="min-w-0 truncate" title={c.email ? `${c.author} <${c.email}>` : c.author}>
|
||||
{c.author}
|
||||
</span>
|
||||
<span className="shrink-0" title={when.toLocaleString()}>
|
||||
{timeAgo(c.date)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @file Compare any two refs (GitLens' Search & Compare, GitHub's compare
|
||||
* view): branches, tags or commits A and B; the commits only in A and only in
|
||||
* B, and the files that differ — "Changes since they diverged" (A...B, what B
|
||||
* adds; GitHub's default) or "Difference between the two" (A B, the trees as
|
||||
* they stand). A file opens its diff in the panel's diff viewer; a commit
|
||||
* opens its whole change. Swap flips the two sides.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { CompareResult, CompareFile } from "../../../lib/gitHistory";
|
||||
import { LaneDiffView } from "../LaneDiffView";
|
||||
import { GitModal } from "./GitModal";
|
||||
|
||||
type Tab = "files" | "onlyA" | "onlyB";
|
||||
|
||||
export function CompareRefs({
|
||||
target,
|
||||
initialA,
|
||||
initialB,
|
||||
refs,
|
||||
onClose,
|
||||
onOpenCommit,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
initialA: string;
|
||||
initialB: string;
|
||||
/** Branch names offered in the inputs' suggestion lists. */
|
||||
refs: string[];
|
||||
onClose: () => void;
|
||||
onOpenCommit: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [a, setA] = useState(initialA);
|
||||
const [b, setB] = useState(initialB);
|
||||
const [asked, setAsked] = useState({ a: initialA, b: initialB });
|
||||
const [mode, setMode] = useState<"three" | "two">("three");
|
||||
const [result, setResult] = useState<CompareResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("files");
|
||||
const [file, setFile] = useState<CompareFile["file"] | null>(null);
|
||||
const [fileLoading, setFileLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setFile(null);
|
||||
void api.lanes.gitHistory
|
||||
.compare(target, asked.a, asked.b, mode)
|
||||
.then((r) => live && (r.available ? setResult(r) : setError(t("compareUnavailable"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
|
||||
}, [asked, mode]);
|
||||
|
||||
const openFile = (path: string, from?: string) => {
|
||||
if (!result) return;
|
||||
setFileLoading(true);
|
||||
void api.lanes.gitHistory
|
||||
.compareFile(target, result.a, result.b, mode, path, from)
|
||||
.then((r) => r.available && setFile(r.file))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setFileLoading(false));
|
||||
};
|
||||
|
||||
const input =
|
||||
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5 font-mono text-[11px] text-fg-primary";
|
||||
const tabBtn = (k: Tab, label: string) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
aria-pressed={tab === k}
|
||||
onClick={() => {
|
||||
setTab(k);
|
||||
setFile(null);
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] ${
|
||||
tab === k ? "bg-accent-solid text-white" : "text-fg-secondary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
const commits = tab === "onlyA" ? result?.onlyA : result?.onlyB;
|
||||
|
||||
return (
|
||||
<GitModal tall testId="compare-refs" onClose={onClose} title={t("compareTitle")}>
|
||||
<form
|
||||
className="mb-2 flex flex-wrap items-center gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (a.trim() && b.trim()) setAsked({ a: a.trim(), b: b.trim() });
|
||||
}}
|
||||
>
|
||||
<datalist id="compare-refs-list">
|
||||
{refs.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
<input
|
||||
aria-label={t("compareA")}
|
||||
data-testid="compare-a"
|
||||
list="compare-refs-list"
|
||||
value={a}
|
||||
maxLength={200}
|
||||
onChange={(e) => setA(e.target.value)}
|
||||
className={input}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("swap")}
|
||||
title={t("swap")}
|
||||
onClick={() => {
|
||||
setA(b);
|
||||
setB(a);
|
||||
setAsked({ a: asked.b, b: asked.a });
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
aria-label={t("compareB")}
|
||||
data-testid="compare-b"
|
||||
list="compare-refs-list"
|
||||
value={b}
|
||||
maxLength={200}
|
||||
onChange={(e) => setB(e.target.value)}
|
||||
className={input}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded bg-accent-solid px-2 py-0.5 text-[11px] text-white hover:bg-accent-solid-hover"
|
||||
>
|
||||
{t("compare")}
|
||||
</button>
|
||||
<select
|
||||
data-testid="compare-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value === "two" ? "two" : "three")}
|
||||
className="min-w-0 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="three">{t("modeThree")}</option>
|
||||
<option value="two">{t("modeTwo")}</option>
|
||||
</select>
|
||||
</form>
|
||||
{error && (
|
||||
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!result && !error && (
|
||||
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
)}
|
||||
{result && (
|
||||
<>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1 rounded border border-border bg-surface-2 p-0.5">
|
||||
{tabBtn("files", t("tabFiles", { count: result.files.length }))}
|
||||
{tabBtn("onlyA", t("tabOnly", { name: asked.a, count: result.onlyA.length }))}
|
||||
{tabBtn("onlyB", t("tabOnly", { name: asked.b, count: result.onlyB.length }))}
|
||||
</div>
|
||||
{tab === "files" && !file && (
|
||||
<ul data-testid="compare-files" className="min-h-0 flex-1 overflow-auto">
|
||||
{result.files.length === 0 && (
|
||||
<li className="text-[12px] text-fg-muted">{t("noDifference")}</li>
|
||||
)}
|
||||
{result.files.map((f) => (
|
||||
<li key={f.path}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={fileLoading}
|
||||
onClick={() => openFile(f.path, f.from)}
|
||||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
|
||||
>
|
||||
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
|
||||
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
|
||||
{f.from ? `${f.from} → ${f.path}` : f.path}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono">
|
||||
<span className="text-status-success">+{f.added}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{result.filesTruncated && (
|
||||
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{tab === "files" && file && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFile(null)}
|
||||
className="mb-1 self-start rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("backToFiles")}
|
||||
</button>
|
||||
{file.secret ? (
|
||||
<p className="text-[12px] text-fg-muted">{t("secretFile")}</p>
|
||||
) : file.hunks?.length ? (
|
||||
<LaneDiffView file={file} layout="unified" fill />
|
||||
) : (
|
||||
<p className="text-[12px] text-fg-muted">{t("noTextChange")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab !== "files" && (
|
||||
<ul className="min-h-0 flex-1 overflow-auto">
|
||||
{commits?.length === 0 && (
|
||||
<li className="text-[12px] text-fg-muted">{t("noCommitsSide")}</li>
|
||||
)}
|
||||
{commits?.map((c) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCommit(c.sha)}
|
||||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
<span className="min-w-0 max-w-[30%] truncate text-fg-muted">{c.author}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{result.commitsTruncated && (
|
||||
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file "Create branch here…" and "Create tag here…" from a History commit's
|
||||
* menu: a name (and, for a tag, an optional message making it annotated),
|
||||
* then git-refs.js's `branch-create {branch, from}` / `tag-create {tag,
|
||||
* message?, from}` with that commit's sha as `from` — the server confirms the
|
||||
* sha itself. Creating a branch never switches to it here. Announces
|
||||
* `agenthub:git-changed` so the other views of the workspace read again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { isSubmitEnter } from "../../../lib/keys";
|
||||
import { emitGitChanged } from "../../../lib/gitChanged";
|
||||
import { GitModal, ModalButton } from "./GitModal";
|
||||
|
||||
export function CreateBranchDialog({
|
||||
target,
|
||||
sha,
|
||||
short,
|
||||
kind = "branch",
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
short: string;
|
||||
kind?: "branch" | "tag";
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [name, setName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const tag = kind === "tag";
|
||||
|
||||
const run = async () => {
|
||||
const ref = name.trim();
|
||||
if (!ref) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
const action = tag ? "tag-create" : "branch-create";
|
||||
try {
|
||||
await api.lanes.gitRefAction(
|
||||
target,
|
||||
action,
|
||||
tag
|
||||
? { tag: ref, from: sha, ...(message.trim() ? { message: message.trim() } : {}) }
|
||||
: { branch: ref, from: sha }
|
||||
);
|
||||
toast.success(t(tag ? "tagCreated" : "branchCreated", { name: ref, branch: ref, short }));
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setRunning(false);
|
||||
} finally {
|
||||
emitGitChanged(typeof target === "number" ? target : target.lane, action);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void run();
|
||||
}
|
||||
};
|
||||
const input =
|
||||
"rounded border border-border-light bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-primary";
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
testId="create-ref"
|
||||
onClose={onClose}
|
||||
title={t(tag ? "createTagTitle" : "createBranchTitle", { short })}
|
||||
footer={
|
||||
<>
|
||||
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
|
||||
<ModalButton
|
||||
primary
|
||||
testId="create-ref-confirm"
|
||||
disabled={!name.trim() || running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t(tag ? "createTag" : "createBranch")}
|
||||
</ModalButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="flex flex-col gap-1 text-[12px] text-fg-secondary">
|
||||
{t(tag ? "tagName" : "branchName")}
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="create-ref-name"
|
||||
value={name}
|
||||
maxLength={200}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={submit}
|
||||
className={input}
|
||||
/>
|
||||
</label>
|
||||
{tag && (
|
||||
<label className="mt-2 flex flex-col gap-1 text-[12px] text-fg-secondary">
|
||||
{t("tagMessage")}
|
||||
<input
|
||||
data-testid="create-ref-message"
|
||||
value={message}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={submit}
|
||||
className={input}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-fg-muted">
|
||||
{t(tag ? "createTagHint" : "createBranchHint")}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="mt-1.5 text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @file One file's history (Show History), like GitLens' File History: every
|
||||
* commit that touched it, newest first, followed across renames (`git log
|
||||
* --follow`). A version opens that commit's diff of the file under the name
|
||||
* it had there (the panel's own diff viewer); Blame opens the file as it was
|
||||
* at that version with each line's commit (BlameView), and a rename says
|
||||
* where it came from. Paged with Load more.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { FileVersion } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { BlameView } from "./BlameView";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
export function FileHistory({
|
||||
target,
|
||||
path,
|
||||
reread,
|
||||
onOpenVersion,
|
||||
onViewCommit,
|
||||
onWholeBranch,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
reread: number;
|
||||
/** That version's diff of the file: its name THEN, and the commit. */
|
||||
onOpenVersion: (path: string, sha: string) => void;
|
||||
onViewCommit: (sha: string) => void;
|
||||
onWholeBranch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [versions, setVersions] = useState<FileVersion[] | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blame, setBlame] = useState<{ path: string; rev: string } | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mine = ++seq.current;
|
||||
setVersions(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
void api.lanes.gitHistory
|
||||
.fileHistory(target, path, { limit: PAGE })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setVersions(r.available ? r.commits : []);
|
||||
setHasMore(r.available && r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
|
||||
}, [path, reread]);
|
||||
|
||||
const more = useCallback(() => {
|
||||
if (!versions || loading) return;
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
void api.lanes.gitHistory
|
||||
.fileHistory(target, path, { limit: PAGE, skip: versions.length })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine || !r.available) return;
|
||||
setVersions((v) => [...(v ?? []), ...r.commits]);
|
||||
setHasMore(r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
}, [versions, loading, target, path]);
|
||||
|
||||
return (
|
||||
<div data-testid="file-history" className="flex min-h-0 flex-col gap-1">
|
||||
<div
|
||||
data-testid="lane-changes-history-file"
|
||||
className="flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
||||
{t("lanes:changes.historyOfFile", { path })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlame({ path, rev: "work" })}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("blame")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWholeBranch}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("lanes:changes.historyWholeBranch")}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{versions === null && !error && (
|
||||
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
)}
|
||||
{versions && versions.length === 0 && (
|
||||
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.noCommits")}</p>
|
||||
)}
|
||||
<ul>
|
||||
{versions?.map((v) => (
|
||||
<li key={v.sha} className="group flex items-center gap-1 rounded px-1 hover:bg-surface-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenVersion(v.path, v.sha)}
|
||||
className="flex min-w-0 flex-1 flex-col py-0.5 text-left"
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className="w-3 shrink-0 font-mono text-[10px] text-fg-muted"
|
||||
title={t(`status.${v.status}`, { defaultValue: v.status })}
|
||||
>
|
||||
{v.status}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{v.short}</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"
|
||||
title={v.subject}
|
||||
>
|
||||
{v.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5 pl-[1.2rem] text-[10px] text-fg-muted">
|
||||
<span className="min-w-0 truncate">{v.author}</span>
|
||||
<span className="shrink-0" title={new Date(v.date).toLocaleString()}>
|
||||
{timeAgo(v.date)}
|
||||
</span>
|
||||
{v.from && (
|
||||
<span className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{t("renamedFrom", { from: v.from })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{v.status !== "D" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlame({ path: v.path, rev: v.sha })}
|
||||
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{t("blame")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewCommit(v.sha)}
|
||||
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{t("commit")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="file-history-more"
|
||||
disabled={loading}
|
||||
onClick={more}
|
||||
className="w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
|
||||
</button>
|
||||
)}
|
||||
{blame && (
|
||||
<BlameView
|
||||
target={target}
|
||||
path={blame.path}
|
||||
rev={blame.rev}
|
||||
onClose={() => setBlame(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setBlame(null);
|
||||
onViewCommit(sha);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file ForcePushDialog.tsx
|
||||
* @description "Force push (with lease)" for the CURRENT branch only: shows
|
||||
* where it goes (remote and branch), the remote commit it will replace (the
|
||||
* lease — the server refuses with ESTALE if the remote moved since the last
|
||||
* fetch), how far ahead/behind HEAD is, and refuses a protected branch unless
|
||||
* the server allows it. Push is a two-click (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
|
||||
export function ForcePushDialog({
|
||||
target,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<PushPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planPush(target)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
if (!isArmed("push")) return arm("push");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true });
|
||||
toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch }));
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("forcePush.title")}
|
||||
onClose={onClose}
|
||||
testId="force-push-dialog"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="force-push-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || !plan || !plan.allowed}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{isArmed("push") ? t("forcePush.runConfirm") : t("forcePush.run")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p data-testid="force-push-where" className="break-words">
|
||||
{t("forcePush.where", {
|
||||
branch: plan.branch,
|
||||
remote: plan.remote,
|
||||
remoteBranch: plan.remoteBranch,
|
||||
})}
|
||||
</p>
|
||||
<p className="break-words">
|
||||
{plan.remoteSha
|
||||
? t("forcePush.replaces", {
|
||||
sha: plan.remoteSha.slice(0, 7),
|
||||
ahead: plan.ahead,
|
||||
behind: plan.behind,
|
||||
})
|
||||
: t("forcePush.newBranch")}
|
||||
</p>
|
||||
{plan.behind > 0 && <Warn>{t("forcePush.dropsRemote", { count: plan.behind })}</Warn>}
|
||||
{!plan.allowed && <Warn testId="force-push-protected">{t("forcePush.protected")}</Warn>}
|
||||
<p className="text-[11px] text-fg-muted">{t("forcePush.lease")}</p>
|
||||
<ErrorLine error={error} />
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @file GitModal.tsx
|
||||
* @description The shared modal shell of the Changes panel's git tools (merge
|
||||
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
|
||||
* portal over the screen with `useDialog`'s focus contract, a title row with a
|
||||
* close button, a scrolling body and an optional footer. `wide` widens it for
|
||||
* the three-pane merge editor; `tall` gives a viewer (blame, browse at a
|
||||
* revision, compare refs) the full height and a column body its own scrolling
|
||||
* child can fill; on a phone every size is the full width. `ModalButton` is
|
||||
* the History tools' footer button over the same BTN classes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../../../hooks/useDialog";
|
||||
|
||||
export const BTN =
|
||||
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const BTN_PRIMARY =
|
||||
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const BTN_DANGER =
|
||||
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
export function GitModal({
|
||||
title,
|
||||
onClose,
|
||||
testId,
|
||||
wide = false,
|
||||
tall = false,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
wide?: boolean;
|
||||
/** A viewer: the full height, and a flex-column body. */
|
||||
tall?: boolean;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDialog(true, panel, onClose);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid={testId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
|
||||
wide || tall ? "max-w-6xl" : "max-w-lg"
|
||||
} ${tall ? "h-[92vh]" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("close")}
|
||||
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`min-h-0 flex-1 px-3 py-2 text-xs text-fg-secondary ${
|
||||
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** A red inline error line, or nothing. */
|
||||
export function ErrorLine({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** An amber explanation box shown before a rewrite runs. */
|
||||
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
|
||||
export function ModalButton({
|
||||
onClick,
|
||||
children,
|
||||
primary = false,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
testId,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @file GitTools.tsx
|
||||
* @description The Changes panel's entry points to the history-rewrite and
|
||||
* remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive
|
||||
* rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool
|
||||
* is open, so the panel holds one `GitTool | null` state), and
|
||||
* `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the
|
||||
* banner's own Complete/Abort, which the server maps onto the rebase).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { History } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu";
|
||||
import { ForcePushDialog } from "./ForcePushDialog";
|
||||
import { InteractiveRebase } from "./InteractiveRebase";
|
||||
import { MergeEditor } from "./MergeEditor";
|
||||
import { RebaseDialog } from "./RebaseDialog";
|
||||
import { ReflogView } from "./ReflogView";
|
||||
import { RemotesDialog } from "./RemotesDialog";
|
||||
import { ResetDialog } from "./ResetDialog";
|
||||
|
||||
export type GitTool =
|
||||
| { kind: "rebase" }
|
||||
| { kind: "interactive"; base?: string }
|
||||
| { kind: "reset"; commit: { sha: string; short?: string; subject?: string } }
|
||||
| { kind: "reflog" }
|
||||
| { kind: "force-push" }
|
||||
| { kind: "remotes" }
|
||||
| { kind: "merge"; path: string; onResolved?: () => void };
|
||||
|
||||
export function GitToolsButton({
|
||||
onPick,
|
||||
disabled,
|
||||
hasRemote,
|
||||
}: {
|
||||
onPick: (tool: GitTool) => void;
|
||||
disabled?: boolean;
|
||||
hasRemote?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const pick = (tool: GitTool) => () => {
|
||||
setAt(null);
|
||||
onPick(tool);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-testid="git-tools-button"
|
||||
aria-label={t("menu.label")}
|
||||
title={t("menu.label")}
|
||||
aria-haspopup="menu"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
setAt(r ? { x: r.left, y: r.bottom + 2 } : { x: 0, y: 0 });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{at && (
|
||||
<ContextMenu x={at.x} y={at.y} onClose={() => setAt(null)} testId="git-tools-menu">
|
||||
<MenuItem testId="git-tools-rebase" onSelect={pick({ kind: "rebase" })}>
|
||||
{t("menu.rebase")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-interactive" onSelect={pick({ kind: "interactive" })}>
|
||||
{t("menu.interactive")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-reflog" onSelect={pick({ kind: "reflog" })}>
|
||||
{t("menu.reflog")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
testId="git-tools-force-push"
|
||||
danger
|
||||
disabled={hasRemote === false}
|
||||
onSelect={pick({ kind: "force-push" })}
|
||||
>
|
||||
{t("menu.forcePush")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-remotes" onSelect={pick({ kind: "remotes" })}>
|
||||
{t("menu.remotes")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitTools({
|
||||
tool,
|
||||
target,
|
||||
branch,
|
||||
onClose,
|
||||
onOpen,
|
||||
onChanged,
|
||||
onViewCommit,
|
||||
}: {
|
||||
tool: GitTool | null;
|
||||
target: GitTarget;
|
||||
/** The current branch, for titles. */
|
||||
branch: string | null;
|
||||
onClose: () => void;
|
||||
/** Open another tool (the reflog's "Reset here"). */
|
||||
onOpen: (tool: GitTool) => void;
|
||||
/** Re-read the panel's git facts after a write. */
|
||||
onChanged: () => void;
|
||||
onViewCommit?: (sha: string) => void;
|
||||
}) {
|
||||
if (!tool) return null;
|
||||
switch (tool.kind) {
|
||||
case "rebase":
|
||||
return (
|
||||
<RebaseDialog target={target} current={branch} onClose={onClose} onChanged={onChanged} />
|
||||
);
|
||||
case "interactive":
|
||||
return (
|
||||
<InteractiveRebase
|
||||
target={target}
|
||||
base={tool.base}
|
||||
onClose={onClose}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
case "reset":
|
||||
return (
|
||||
<ResetDialog target={target} commit={tool.commit} onClose={onClose} onChanged={onChanged} />
|
||||
);
|
||||
case "reflog":
|
||||
return (
|
||||
<ReflogView
|
||||
target={target}
|
||||
onClose={onClose}
|
||||
onViewCommit={
|
||||
onViewCommit &&
|
||||
((sha) => {
|
||||
onClose();
|
||||
onViewCommit(sha);
|
||||
})
|
||||
}
|
||||
onReset={(e) =>
|
||||
onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } })
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "force-push":
|
||||
return <ForcePushDialog target={target} onClose={onClose} onChanged={onChanged} />;
|
||||
case "remotes":
|
||||
return <RemotesDialog target={target} onClose={onClose} onChanged={onChanged} />;
|
||||
case "merge":
|
||||
return (
|
||||
<MergeEditor
|
||||
target={target}
|
||||
path={tool.path}
|
||||
onClose={onClose}
|
||||
onResolved={() => {
|
||||
onChanged();
|
||||
tool.onResolved?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rebase banner's Skip: drops the commit the rebase stopped on. */
|
||||
export function RebaseSkipButton({
|
||||
target,
|
||||
disabled,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
disabled?: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rebase-skip"
|
||||
title={t("rebaseBanner.skipHint")}
|
||||
disabled={disabled || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase-skip", {});
|
||||
if (r.conflicts?.length) toast.error(t("rebase.stopped", { count: r.conflicts.length }));
|
||||
else if (!r.stopped) toast.success(t("rebaseBanner.finished"));
|
||||
} catch (e) {
|
||||
toast.error(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
onChanged();
|
||||
}
|
||||
}}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("rebaseBanner.skip")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* @file The Changes panel's History tab, shaped like VS Code's Source Control
|
||||
* Graph, GitLens and the JetBrains git log: a commit graph (lanes and merge
|
||||
* edges, ref badges) read page by page and drawn windowed, so a repo with
|
||||
* tens of thousands of commits stays responsive; a search box (message, a SHA
|
||||
* prefix, `author:`, `path:`) and an all-branches switch; a details pane for
|
||||
* the focused commit; multi-select (checkbox, Ctrl/⌘-click, Shift-click) with
|
||||
* "Cherry-pick N" / "Revert N" that open a preview of exactly what will run
|
||||
* (CherryPickDialog); the reference tools' commit context menu; and, after a
|
||||
* pick or revert, a result banner with Undo while that is safe (the server
|
||||
* refuses it once the commits are pushed or HEAD moved on).
|
||||
*
|
||||
* Every write still goes through the server's guards (git-pick.js); a
|
||||
* disabled button here is a courtesy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitCompare, Search, X } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { LaneCommit } from "../../../lib/types";
|
||||
import { commitGraph } from "../../../lib/commitGraph";
|
||||
import { parseSearch, inListOrder, type PickResult } from "../../../lib/gitHistory";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../../ContextMenu";
|
||||
import { GRAPH_ROW, GraphCell, CommitText } from "./CommitGraph";
|
||||
import { CommitDetails } from "./CommitDetails";
|
||||
import { CherryPickDialog } from "./CherryPickDialog";
|
||||
import { BrowseAtRevision } from "./BrowseAtRevision";
|
||||
import { CompareRefs } from "./CompareRefs";
|
||||
import { CreateBranchDialog } from "./CreateBranchDialog";
|
||||
|
||||
/** Commits one page reads. */
|
||||
export const PAGE = 100;
|
||||
/** Rows drawn beyond the visible ones, each side. */
|
||||
const OVERSCAN = 20;
|
||||
|
||||
type Op = "cherry-pick" | "revert";
|
||||
|
||||
export function HistoryView({
|
||||
target,
|
||||
branch,
|
||||
branches,
|
||||
historyRef,
|
||||
onHistoryRef,
|
||||
reread,
|
||||
disabled,
|
||||
headPushed,
|
||||
onViewCommit,
|
||||
onOpenFileAtCommit,
|
||||
onUndoCommit,
|
||||
onResetHere,
|
||||
onEditAfter,
|
||||
onPaused,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** The branch HEAD is on (null = detached / unknown). */
|
||||
branch: string | null;
|
||||
/** Local branches, for "History of branch". */
|
||||
branches: string[];
|
||||
historyRef: string | null;
|
||||
onHistoryRef: (ref: string | null) => void;
|
||||
/** Bumped by the panel's Refresh and after its own writes. */
|
||||
reread: number;
|
||||
/** A write is running, or a merge/pick is open: nothing new starts. */
|
||||
disabled: boolean;
|
||||
/** The branch's upstream already holds HEAD (Undo commit refused). */
|
||||
headPushed: boolean;
|
||||
onViewCommit: (sha: string) => void;
|
||||
onOpenFileAtCommit: (path: string, sha: string) => void;
|
||||
onUndoCommit: (c: LaneCommit) => void;
|
||||
/** "Reset branch to here…" — the rewrite tools' reset dialog (GitTools). */
|
||||
onResetHere: (c: LaneCommit) => void;
|
||||
/** "Edit commits after this one…" — the interactive rebase (GitTools). */
|
||||
onEditAfter: (sha: string) => void;
|
||||
/** A pick/revert stopped on a conflict: the overview resolves it. */
|
||||
onPaused: () => void;
|
||||
/** History changed (a pick, revert or undo): re-read everything. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes", "gitTools"]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState(() => parseSearch(""));
|
||||
const [all, setAll] = useState(false);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [focused, setFocused] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const anchor = useRef<string | null>(null);
|
||||
const [dialog, setDialog] = useState<{ op: Op; shas: string[] } | null>(null);
|
||||
const [browse, setBrowse] = useState<{ sha: string; short: string } | null>(null);
|
||||
const [compare, setCompare] = useState<{ a: string; b: string } | null>(null);
|
||||
const [newRef, setNewRef] = useState<{
|
||||
sha: string;
|
||||
short: string;
|
||||
kind: "branch" | "tag";
|
||||
} | null>(null);
|
||||
const [lastPick, setLastPick] = useState<(PickResult & { op: Op }) | null>(null);
|
||||
const menu = useContextMenu<LaneCommit>();
|
||||
|
||||
// The search box applies after a pause in typing, not on every key.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setFilter(parseSearch(search)), 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const filtered = !!(filter.q || filter.author || filter.path);
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
...(all ? { all: true } : historyRef ? { ref: historyRef } : {}),
|
||||
...(filter.q ? { q: filter.q } : {}),
|
||||
...(filter.author ? { author: filter.author } : {}),
|
||||
...(filter.path ? { path: filter.path } : {}),
|
||||
}),
|
||||
[all, historyRef, filter]
|
||||
);
|
||||
const queryKey = JSON.stringify(query);
|
||||
|
||||
// The first page whenever the question changes; a reply for an older
|
||||
// question is dropped.
|
||||
const seq = useRef(0);
|
||||
useEffect(() => {
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: PAGE, ...query })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setCommits(r.available ? r.commits : []);
|
||||
setHasMore(!!r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
|
||||
}, [queryKey, reread]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore || !commits) return;
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: PAGE, skip: commits.length, ...query })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setCommits((prev) => {
|
||||
const seen = new Set((prev ?? []).map((c) => c.sha));
|
||||
return [...(prev ?? []), ...r.commits.filter((c) => !seen.has(c.sha))];
|
||||
});
|
||||
setHasMore(!!r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
}, [loading, hasMore, commits, target, query]);
|
||||
|
||||
const list = useMemo(() => commits ?? [], [commits]);
|
||||
// A search result is not a connected history: dots, no lanes.
|
||||
const graph = useMemo(
|
||||
() => commitGraph(filtered ? list.map((c) => ({ sha: c.sha, parents: [] })) : list),
|
||||
[list, filtered]
|
||||
);
|
||||
const byShaIndex = useMemo(() => new Map(list.map((c, i) => [c.sha, i])), [list]);
|
||||
|
||||
// Which loaded commits HEAD already has: Revert applies to those,
|
||||
// Cherry-pick to the rest. On HEAD's own history, all of them.
|
||||
const onHead = useMemo(() => {
|
||||
if (!all && !historyRef) return null;
|
||||
const head = list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"));
|
||||
const set = new Set<string>();
|
||||
if (!head) return set;
|
||||
const stack = [head.sha];
|
||||
while (stack.length) {
|
||||
const s = stack.pop()!;
|
||||
if (set.has(s) || !byShaIndex.has(s)) continue;
|
||||
set.add(s);
|
||||
stack.push(...(list[byShaIndex.get(s)!]!.parents ?? []));
|
||||
}
|
||||
return set;
|
||||
}, [all, historyRef, list, byShaIndex]);
|
||||
// The commit HEAD is on: Edit commits after it has nothing to edit.
|
||||
const headSha = useMemo(
|
||||
() =>
|
||||
list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"))?.sha ??
|
||||
(!all && !historyRef ? list[0]?.sha : undefined),
|
||||
[list, all, historyRef]
|
||||
);
|
||||
const isOnHead = useCallback((sha: string) => (onHead ? onHead.has(sha) : true), [onHead]);
|
||||
|
||||
// ---- selection -------------------------------------------------------
|
||||
const clickRow = (e: MouseEvent, c: LaneCommit) => {
|
||||
setFocused(c.sha);
|
||||
if (e.shiftKey && anchor.current && byShaIndex.has(anchor.current)) {
|
||||
const a = byShaIndex.get(anchor.current)!;
|
||||
const b = byShaIndex.get(c.sha)!;
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
setPicked(new Set(list.slice(lo, hi + 1).map((x) => x.sha)));
|
||||
return;
|
||||
}
|
||||
anchor.current = c.sha;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
toggle(c.sha);
|
||||
return;
|
||||
}
|
||||
setPicked(new Set());
|
||||
};
|
||||
const toggle = (sha: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sha)) next.delete(sha);
|
||||
else next.add(sha);
|
||||
return next;
|
||||
});
|
||||
const selected = inListOrder(list, picked);
|
||||
const mergeIn = selected.some((c) => c.parents.length > 1);
|
||||
const allOnHead = selected.length > 0 && selected.every((c) => isOnHead(c.sha));
|
||||
const noneOnHead = selected.length > 0 && selected.every((c) => !isOnHead(c.sha));
|
||||
const target_branch = branch ?? "HEAD";
|
||||
|
||||
const openPick = (op: Op, shas: string[]) => setDialog({ op, shas });
|
||||
|
||||
const afterPick = (op: Op, r: PickResult) => {
|
||||
setDialog(null);
|
||||
setPicked(new Set());
|
||||
if (r.paused) {
|
||||
toast.error(
|
||||
r.empty
|
||||
? t("pausedEmpty", { op: t(`op.${op}`) })
|
||||
: t("pausedConflicts", { op: t(`op.${op}`), count: r.conflicts.length })
|
||||
);
|
||||
onPaused();
|
||||
return;
|
||||
}
|
||||
const n = r.commits?.length ?? 0;
|
||||
toast.success(
|
||||
t(op === "revert" ? "revertedN" : "pickedN", { count: n, branch: target_branch })
|
||||
);
|
||||
if (r.from && r.to && n) setLastPick({ ...r, op });
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const undoPick = async () => {
|
||||
if (!lastPick?.from || !lastPick.to) return;
|
||||
const n = lastPick.commits?.length ?? 0;
|
||||
const ok = await confirmDialog({
|
||||
title: t("undoTitle", { op: t(`op.${lastPick.op}`) }),
|
||||
message: t("undoBody", { count: n, branch: target_branch }),
|
||||
confirmLabel: t("undo"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.lanes.gitHistory.act(target, "undo-pick", {
|
||||
from: lastPick.from,
|
||||
to: lastPick.to,
|
||||
confirm: true,
|
||||
});
|
||||
toast.success(t("undone", { count: n }));
|
||||
setLastPick(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const copyPatch = async (c: LaneCommit) => {
|
||||
try {
|
||||
const r = await api.lanes.gitHistory.patch(target, c.sha);
|
||||
if (!r.available) return void toast.error(t("noPatch"));
|
||||
if (r.omitted.length) toast.info(t("patchOmitted", { count: r.omitted.length }));
|
||||
await copyText(r.patch);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- windowing -------------------------------------------------------
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [top, setTop] = useState(0);
|
||||
const [height, setHeight] = useState(600);
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 600));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const first = Math.max(0, Math.floor(top / GRAPH_ROW) - OVERSCAN);
|
||||
const last = Math.min(list.length, Math.ceil((top + height) / GRAPH_ROW) + OVERSCAN);
|
||||
const onScroll = () => {
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
setTop(el.scrollTop);
|
||||
// Near the end: the next page, like an endless log.
|
||||
if (el.scrollTop + el.clientHeight > list.length * GRAPH_ROW - GRAPH_ROW * 10) loadMore();
|
||||
};
|
||||
|
||||
const focusedCommit = focused ? list.find((c) => c.sha === focused) : undefined;
|
||||
const menuItem = menu.menu?.item;
|
||||
const menuMulti = !!menuItem && picked.has(menuItem.sha) && picked.size > 1;
|
||||
const pick = (fn: () => void) => () => {
|
||||
menu.close();
|
||||
fn();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="lane-changes-history" className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
{/* Toolbar: search, all branches, branch, compare. Wraps on a narrow panel. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 text-[11px]">
|
||||
<label className="flex min-w-[10rem] flex-1 items-center gap-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5">
|
||||
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
data-testid="history-search"
|
||||
value={search}
|
||||
maxLength={300}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchLabel")}
|
||||
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("clearSearch")}
|
||||
onClick={() => setSearch("")}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={all}
|
||||
onChange={setAll}
|
||||
label={t("allBranches")}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
{!all && branches.length > 1 && (
|
||||
<label className="flex min-w-0 items-center gap-1 text-fg-secondary">
|
||||
<span className="shrink-0">{t("lanes:changes.historyOf")}</span>
|
||||
<select
|
||||
data-testid="lane-changes-history-ref"
|
||||
value={historyRef ?? ""}
|
||||
onChange={(e) => onHistoryRef(e.target.value || null)}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="">{target_branch}</option>
|
||||
{branches
|
||||
.filter((b) => b !== branch)
|
||||
.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-compare"
|
||||
onClick={() =>
|
||||
setCompare({
|
||||
a: target_branch,
|
||||
b: historyRef ?? branches.find((b) => b !== branch) ?? target_branch,
|
||||
})
|
||||
}
|
||||
className="flex shrink-0 items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<GitCompare className="h-3 w-3" aria-hidden />
|
||||
{t("compare")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastPick && (
|
||||
<div
|
||||
data-testid="pick-result"
|
||||
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-status-success/40 bg-status-success/10 px-2 py-1 text-[11px] text-status-success"
|
||||
>
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
||||
{t(lastPick.op === "revert" ? "revertedN" : "pickedN", {
|
||||
count: lastPick.commits?.length ?? 0,
|
||||
branch: target_branch,
|
||||
})}{" "}
|
||||
<span className="font-mono">
|
||||
{(lastPick.commits ?? []).map((c) => c.short).join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-undo"
|
||||
disabled={disabled}
|
||||
title={t("undoHint")}
|
||||
onClick={() => void undoPick()}
|
||||
className="shrink-0 rounded border border-status-success/50 px-1.5 py-0.5 hover:bg-status-success/15 disabled:opacity-40"
|
||||
>
|
||||
{t("undo")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("dismiss")}
|
||||
onClick={() => setLastPick(null)}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div
|
||||
data-testid="history-selection"
|
||||
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-accent/40 bg-accent/10 px-2 py-1 text-[11px] text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("selectedN", { count: selected.length })}</span>
|
||||
{noneOnHead && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-pick-selected"
|
||||
disabled={disabled || mergeIn}
|
||||
title={mergeIn ? t("mergeCommitRefused") : undefined}
|
||||
onClick={() =>
|
||||
openPick(
|
||||
"cherry-pick",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded bg-accent-solid px-1.5 py-0.5 text-white hover:bg-accent-solid-hover disabled:opacity-40"
|
||||
>
|
||||
{t("pickN", { count: selected.length, branch: target_branch })}
|
||||
</button>
|
||||
)}
|
||||
{allOnHead && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-revert-selected"
|
||||
disabled={disabled || mergeIn}
|
||||
title={mergeIn ? t("mergeCommitRefused") : undefined}
|
||||
onClick={() =>
|
||||
openPick(
|
||||
"revert",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded border border-border px-1.5 py-0.5 hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("revertN", { count: selected.length })}
|
||||
</button>
|
||||
)}
|
||||
{!noneOnHead && !allOnHead && (
|
||||
<span className="text-fg-muted">{t("mixedSelection")}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(new Set())}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("clearSelection")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scroller}
|
||||
onScroll={onScroll}
|
||||
data-testid="history-list"
|
||||
className="min-h-[8rem] flex-1 overflow-y-auto"
|
||||
>
|
||||
{commits === null ? (
|
||||
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
) : list.length === 0 ? (
|
||||
<p className="px-1 text-[11px] text-fg-muted">
|
||||
{filtered ? t("noMatches") : t("lanes:changes.noCommits")}
|
||||
</p>
|
||||
) : (
|
||||
<div role="list" style={{ height: list.length * GRAPH_ROW, position: "relative" }}>
|
||||
{list.slice(first, last).map((c, k) => {
|
||||
const i = first + k;
|
||||
const isPicked = picked.has(c.sha);
|
||||
const head = (c.badges ?? []).some((b) => b.current || b.kind === "head");
|
||||
return (
|
||||
<div
|
||||
key={c.sha}
|
||||
role="listitem"
|
||||
data-sha={c.sha}
|
||||
aria-selected={isPicked || focused === c.sha}
|
||||
onContextMenu={(e) => menu.open(e, c)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: i * GRAPH_ROW,
|
||||
height: GRAPH_ROW,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
className={`group flex items-center gap-1 rounded px-1 ${
|
||||
isPicked
|
||||
? "bg-accent/15"
|
||||
: focused === c.sha
|
||||
? "bg-surface-3"
|
||||
: "hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isPicked}
|
||||
onChange={() => {
|
||||
// A tick is an anchor too: Shift-click extends from it.
|
||||
anchor.current = c.sha;
|
||||
toggle(c.sha);
|
||||
}}
|
||||
title={t("selectCommit", { short: c.short })}
|
||||
className={isPicked || picked.size ? "" : "opacity-40 group-hover:opacity-100"}
|
||||
/>
|
||||
<GraphCell
|
||||
row={graph.rows[i]!}
|
||||
width={graph.width}
|
||||
merge={c.parents.length > 1}
|
||||
head={head}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => clickRow(e, c)}
|
||||
onDoubleClick={() => onViewCommit(c.sha)}
|
||||
className="flex h-full min-w-0 flex-1 items-center text-left"
|
||||
>
|
||||
<CommitText c={c} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={loading}
|
||||
onClick={loadMore}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{focusedCommit && (
|
||||
<CommitDetails
|
||||
key={focusedCommit.sha}
|
||||
target={target}
|
||||
sha={focusedCommit.sha}
|
||||
onClose={() => setFocused(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setFocused(sha);
|
||||
const el = scroller.current?.querySelector(`[data-sha="${sha}"]`);
|
||||
(el as HTMLElement | null)?.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
onViewChanges={() => onViewCommit(focusedCommit.sha)}
|
||||
onOpenFile={(path) => onOpenFileAtCommit(path, focusedCommit.sha)}
|
||||
onBrowse={() => setBrowse({ sha: focusedCommit.sha, short: focusedCommit.short })}
|
||||
onCompare={() => setCompare({ a: target_branch, b: focusedCommit.sha })}
|
||||
onCopyPatch={() => void copyPatch(focusedCommit)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{menu.menu && menuItem && (
|
||||
<ContextMenu
|
||||
x={menu.menu.x}
|
||||
y={menu.menu.y}
|
||||
onClose={menu.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(() => onViewCommit(menuItem.sha))}>
|
||||
{t("lanes:changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setFocused(menuItem.sha))}>{t("showDetails")}</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setBrowse({ sha: menuItem.sha, short: menuItem.short }))}>
|
||||
{t("browseFiles")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setCompare({ a: target_branch, b: menuItem.sha }))}>
|
||||
{t("compareWithCurrent")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(() => void copyText(menuItem.sha))}>
|
||||
{t("lanes:changes.copySha")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void copyText(menuItem.subject))}>
|
||||
{t("lanes:changes.copySubject")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={menuItem.parents.length > 1}
|
||||
onSelect={pick(() => void copyPatch(menuItem))}
|
||||
>
|
||||
{t("copyPatch")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="history-menu-branch"
|
||||
disabled={disabled}
|
||||
onSelect={pick(() =>
|
||||
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "branch" })
|
||||
)}
|
||||
>
|
||||
{t("createBranchHere")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="history-menu-tag"
|
||||
disabled={disabled}
|
||||
onSelect={pick(() =>
|
||||
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "tag" })
|
||||
)}
|
||||
>
|
||||
{t("createTagHere")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{menuMulti ? (
|
||||
<>
|
||||
{noneOnHead && (
|
||||
<MenuItem
|
||||
disabled={disabled || mergeIn}
|
||||
onSelect={pick(() =>
|
||||
openPick(
|
||||
"cherry-pick",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("pickN", { count: selected.length, branch: target_branch })}
|
||||
</MenuItem>
|
||||
)}
|
||||
{allOnHead && (
|
||||
<MenuItem
|
||||
disabled={disabled || mergeIn}
|
||||
onSelect={pick(() =>
|
||||
openPick(
|
||||
"revert",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("revertN", { count: selected.length })}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
) : isOnHead(menuItem.sha) ? (
|
||||
<>
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.parents.length !== 1}
|
||||
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
|
||||
testId="lane-changes-menu-revert"
|
||||
onSelect={pick(() => openPick("revert", [menuItem.sha]))}
|
||||
>
|
||||
{t("lanes:changes.revertCommit")}
|
||||
</MenuItem>
|
||||
{!all && !historyRef && list[0]?.sha === menuItem.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={disabled || headPushed || menuItem.parents.length !== 1}
|
||||
title={headPushed ? t("lanes:changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(() => onUndoCommit(menuItem))}
|
||||
>
|
||||
{t("lanes:changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* History rewrites (GitTools): each opens a dialog that asks first. */}
|
||||
<MenuItem
|
||||
disabled={disabled}
|
||||
testId="lane-changes-menu-reset"
|
||||
onSelect={pick(() => onResetHere(menuItem))}
|
||||
>
|
||||
{t("gitTools:menu.resetHere")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.sha === headSha}
|
||||
testId="lane-changes-menu-interactive"
|
||||
onSelect={pick(() => onEditAfter(menuItem.sha))}
|
||||
>
|
||||
{t("gitTools:menu.interactiveHere")}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.parents.length !== 1}
|
||||
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
|
||||
testId="lane-changes-menu-cherry-pick"
|
||||
onSelect={pick(() => openPick("cherry-pick", [menuItem.sha]))}
|
||||
>
|
||||
{t("lanes:changes.cherryPick", { branch: target_branch })}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CherryPickDialog
|
||||
target={target}
|
||||
op={dialog.op}
|
||||
shas={dialog.shas}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={(r) => afterPick(dialog.op, r)}
|
||||
/>
|
||||
)}
|
||||
{browse && (
|
||||
<BrowseAtRevision
|
||||
target={target}
|
||||
sha={browse.sha}
|
||||
short={browse.short}
|
||||
onClose={() => setBrowse(null)}
|
||||
/>
|
||||
)}
|
||||
{compare && (
|
||||
<CompareRefs
|
||||
target={target}
|
||||
initialA={compare.a}
|
||||
initialB={compare.b}
|
||||
refs={branches}
|
||||
onClose={() => setCompare(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setCompare(null);
|
||||
onViewCommit(sha);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newRef && (
|
||||
<CreateBranchDialog
|
||||
target={target}
|
||||
sha={newRef.sha}
|
||||
short={newRef.short}
|
||||
kind={newRef.kind}
|
||||
onClose={() => setNewRef(null)}
|
||||
onDone={() => {
|
||||
setNewRef(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @file InteractiveRebase.tsx
|
||||
* @description Interactive rebase as a list (JetBrains' "Interactively Rebase
|
||||
* from Here"): the branch's commits since a base — by default the ones no
|
||||
* remote has yet, oldest first — each with Pick / Reword / Squash / Fixup /
|
||||
* Drop, moved up and down, a new message for a reword or a squash. Before it
|
||||
* runs it says what it rewrites ("This rewrites 4 commits; 2 are already
|
||||
* pushed, so a force-push will be needed") and pushed commits need an explicit
|
||||
* tick; Start is a two-click (`useArmed`). The server builds the todo and runs
|
||||
* `git rebase -i` with no editor; a clean run stays open with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import {
|
||||
errText,
|
||||
gitRewrite,
|
||||
type InteractivePlan,
|
||||
type PlanCommit,
|
||||
type RewriteResult,
|
||||
type TodoAction,
|
||||
} from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
import { blockedReason } from "./RebaseDialog";
|
||||
|
||||
const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"];
|
||||
|
||||
interface Row {
|
||||
commit: PlanCommit;
|
||||
action: TodoAction;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Why the list cannot run as it stands, or null. */
|
||||
export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null {
|
||||
const kept = rows.filter((r) => r.action !== "drop");
|
||||
if (!kept.length) return "allDropped";
|
||||
if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first";
|
||||
if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function InteractiveRebase({
|
||||
target,
|
||||
base,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** Rewrite the commits after this one; absent = the unpushed ones. */
|
||||
base?: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<InteractivePlan | null>(null);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [force, setForce] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planInteractive(target, base)
|
||||
.then((p) => {
|
||||
if (!live) return;
|
||||
setPlan(p);
|
||||
setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message })));
|
||||
})
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, base]);
|
||||
|
||||
const set = (i: number, patch: Partial<Row>) =>
|
||||
setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r)));
|
||||
const move = (i: number, step: number) =>
|
||||
setRows((prev) => {
|
||||
const j = i + step;
|
||||
if (j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[i], next[j]] = [next[j]!, next[i]!];
|
||||
return next;
|
||||
});
|
||||
|
||||
const blocked = plan ? blockedReason(t, plan) : null;
|
||||
const problem = todoProblem(rows);
|
||||
const unchanged =
|
||||
!!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha);
|
||||
const pushed = plan?.pushed ?? 0;
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
if (!isArmed("run")) return arm("run");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase-interactive", {
|
||||
head: plan.head,
|
||||
...(base ? { base: plan.base } : {}),
|
||||
confirm: true,
|
||||
...(pushed ? { force: true } : {}),
|
||||
todo: rows.map((row) => ({
|
||||
sha: row.commit.sha,
|
||||
action: row.action,
|
||||
...((row.action === "reword" || row.action === "squash") &&
|
||||
row.message.trim() &&
|
||||
row.message.trim() !== row.commit.message
|
||||
? { message: row.message }
|
||||
: row.action === "reword"
|
||||
? { message: row.message }
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
onChanged();
|
||||
if (r.conflicts?.length || r.stopped) {
|
||||
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success(t("interactive.done"));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("interactive.title")}
|
||||
onClose={onClose}
|
||||
testId="interactive-rebase"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="interactive-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={
|
||||
busy || !plan || !!blocked || !!problem || unchanged || (pushed > 0 && !force)
|
||||
}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{isArmed("run") ? t("interactive.runConfirm") : t("interactive.run")}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : !plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-1.5 text-[11px] text-fg-muted">{t("interactive.explain")}</p>
|
||||
{blocked && <Warn>{blocked}</Warn>}
|
||||
{plan.merges && <Warn>{t("interactive.merges")}</Warn>}
|
||||
{!rows.length ? (
|
||||
<p data-testid="interactive-empty">{t("interactive.nothing")}</p>
|
||||
) : (
|
||||
<>
|
||||
<Warn testId="interactive-summary">
|
||||
{pushed
|
||||
? t("rewritesPushed", { count: rows.length, pushed })
|
||||
: t("rewrites", { count: rows.length })}
|
||||
{pushed > 0 && (
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="interactive-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
)}
|
||||
</Warn>
|
||||
<ol data-testid="interactive-list" className="space-y-1">
|
||||
{rows.map((r, i) => (
|
||||
<li
|
||||
key={r.commit.sha}
|
||||
data-testid="interactive-row"
|
||||
className={`rounded border border-border-light p-1.5 ${
|
||||
r.action === "drop" ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("interactive.action", { short: r.commit.short })}
|
||||
data-testid="interactive-action"
|
||||
value={r.action}
|
||||
onChange={(e) => set(i, { action: e.target.value as TodoAction })}
|
||||
className="input py-0.5 text-[11px]"
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{t(`interactive.actions.${a}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{r.commit.short}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-[11px] text-fg-primary ${
|
||||
r.action === "drop" ? "line-through" : ""
|
||||
}`}
|
||||
title={r.commit.subject}
|
||||
>
|
||||
{r.commit.subject}
|
||||
</span>
|
||||
{r.commit.pushed && (
|
||||
<span className="shrink-0 text-[10px] text-status-warning">
|
||||
{t("pushed")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("interactive.up")}
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("interactive.down")}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{(r.action === "reword" || r.action === "squash") && (
|
||||
<textarea
|
||||
data-testid="interactive-message"
|
||||
aria-label={t("interactive.message")}
|
||||
value={r.message}
|
||||
rows={3}
|
||||
onChange={(e) => set(i, { message: e.target.value })}
|
||||
placeholder={
|
||||
r.action === "squash" ? t("interactive.squashHint") : undefined
|
||||
}
|
||||
className="input mt-1 w-full font-mono text-[11px]"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{problem && (
|
||||
<p
|
||||
data-testid="interactive-problem"
|
||||
className="mt-1 text-[11px] text-status-danger"
|
||||
>
|
||||
{t(`interactive.problem.${problem}`)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* @file MergeEditor.tsx
|
||||
* @description The 3-way merge editor for ONE conflicted file — a merge,
|
||||
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
|
||||
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
|
||||
* base (stage :1) one click away. Each conflict block of the result takes
|
||||
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
|
||||
* undoable; next/previous conflict and "N conflicts left"; or the whole result
|
||||
* edited as text. Save writes the result through the `conflict-save` action —
|
||||
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
|
||||
* then `git add` — which the server refuses while a marker is left.
|
||||
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
|
||||
* commit being replayed, so the labels say that rather than "mine".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
|
||||
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
export type BlockChoice =
|
||||
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
|
||||
|
||||
type Block = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
|
||||
/** The lines a block resolves to, or null while it is unresolved. */
|
||||
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
|
||||
if (!c) return null;
|
||||
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
|
||||
if (c.kind === "ours") return p.ours;
|
||||
if (c.kind === "theirs") return p.theirs;
|
||||
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
|
||||
return [...p.theirs, ...p.ours];
|
||||
}
|
||||
|
||||
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
|
||||
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
for (const p of parts) {
|
||||
if (p.kind === "text") {
|
||||
out.push(...p.lines);
|
||||
continue;
|
||||
}
|
||||
const lines = blockLines(p, choices[i++]);
|
||||
if (lines) out.push(...lines);
|
||||
else
|
||||
out.push(
|
||||
`<<<<<<< ${p.oursLabel}`.trimEnd(),
|
||||
...p.ours,
|
||||
"=======",
|
||||
...p.theirs,
|
||||
`>>>>>>> ${p.theirsLabel}`.trimEnd()
|
||||
);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function Pane({
|
||||
title,
|
||||
text,
|
||||
testId,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
text: string | null;
|
||||
testId: string;
|
||||
tone: "incoming" | "current" | "base";
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const head =
|
||||
tone === "incoming"
|
||||
? "bg-accent/20 text-accent"
|
||||
: tone === "current"
|
||||
? "bg-status-success/20 text-status-success"
|
||||
: "bg-surface-3 text-fg-secondary";
|
||||
return (
|
||||
<section
|
||||
data-testid={testId}
|
||||
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
|
||||
>
|
||||
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
|
||||
{title}
|
||||
</h3>
|
||||
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
|
||||
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
|
||||
</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MergeEditor({
|
||||
target,
|
||||
path,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
onClose: () => void;
|
||||
/** The file was saved and marked resolved. */
|
||||
onResolved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [sides, setSides] = useState<ConflictSides | null>(null);
|
||||
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
|
||||
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
|
||||
const [whole, setWhole] = useState<string | null>(null);
|
||||
const [showBase, setShowBase] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [at, setAt] = useState(0);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
let live = true;
|
||||
setSides(null);
|
||||
setChoices([]);
|
||||
setEditing(null);
|
||||
setWhole(null);
|
||||
setError(null);
|
||||
gitRewrite
|
||||
.conflict(target, path)
|
||||
.then((s) => live && setSides(s))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `target` is rebuilt by the caller on every render; its parts are stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(target), path]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const parts =
|
||||
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
|
||||
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
|
||||
const total = blocks.length;
|
||||
const left = blocks.filter((_, i) => !choices[i]).length;
|
||||
|
||||
const rebase = sides?.operation === "rebase";
|
||||
const incomingLabel = sides?.incoming
|
||||
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
|
||||
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
|
||||
})
|
||||
: t("merge.incomingPlain");
|
||||
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
|
||||
branch: sides?.branch ?? "HEAD",
|
||||
});
|
||||
|
||||
const choose = (i: number, c: BlockChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = c;
|
||||
return next;
|
||||
});
|
||||
|
||||
const jump = (step: number) => {
|
||||
if (!total) return;
|
||||
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
|
||||
const pool = open.length ? open : blocks.map((_, i) => i);
|
||||
const from = pool.findIndex((i) => i > at);
|
||||
let i: number;
|
||||
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
|
||||
else {
|
||||
const before = pool.filter((x) => x < at);
|
||||
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
|
||||
}
|
||||
setAt(i);
|
||||
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!sides?.sha || !parts) return;
|
||||
const content = whole ?? composeResult(parts, choices);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
|
||||
toast.success(t("merge.saved", { path }));
|
||||
onResolved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
setError(
|
||||
code === "EMARKERS"
|
||||
? t("merge.markersLeft")
|
||||
: code === "ESTALE"
|
||||
? t("merge.stale")
|
||||
: errText(e)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
|
||||
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
let blockIndex = -1;
|
||||
const result = parts && (
|
||||
<section
|
||||
data-testid="merge-result"
|
||||
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
|
||||
>
|
||||
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
|
||||
{t("merge.result")}
|
||||
</h3>
|
||||
{whole != null ? (
|
||||
<textarea
|
||||
data-testid="merge-whole"
|
||||
aria-label={t("merge.result")}
|
||||
value={whole}
|
||||
onChange={(e) => setWhole(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
|
||||
{parts.map((p, n) => {
|
||||
if (p.kind === "text")
|
||||
return (
|
||||
<pre
|
||||
key={n}
|
||||
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
|
||||
>
|
||||
{p.lines.length ? p.lines.join("\n") : " "}
|
||||
</pre>
|
||||
);
|
||||
const i = ++blockIndex;
|
||||
const c = choices[i];
|
||||
const lines = blockLines(p, c);
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
ref={(el) => {
|
||||
blockRefs.current[i] = el;
|
||||
}}
|
||||
data-testid="merge-block"
|
||||
className={`my-1 border-y-2 ${
|
||||
c ? "border-status-success/60" : "border-status-warning/70"
|
||||
} ${at === i ? "ring-1 ring-accent" : ""}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
|
||||
<span className="mr-auto text-fg-muted">
|
||||
{t("merge.blockOf", { n: i + 1, total })}
|
||||
</span>
|
||||
{editing?.i === i ? (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => {
|
||||
choose(i, { kind: "custom", text: editing.text });
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
{t("merge.editDone")}
|
||||
</button>
|
||||
) : c ? (
|
||||
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
||||
{t("merge.undoChoice")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "ours" },
|
||||
t("merge.acceptCurrent"),
|
||||
"merge-accept-current"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "theirs" },
|
||||
t("merge.acceptIncoming"),
|
||||
"merge-accept-incoming"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "ours-theirs" },
|
||||
t("merge.bothCurrentFirst"),
|
||||
"merge-both-ct"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "theirs-ours" },
|
||||
t("merge.bothIncomingFirst"),
|
||||
"merge-both-ic"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editing?.i !== i && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-edit-block"
|
||||
className={BTN}
|
||||
onClick={() =>
|
||||
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
|
||||
}
|
||||
>
|
||||
{t("merge.editBlock")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing?.i === i ? (
|
||||
<textarea
|
||||
data-testid="merge-block-text"
|
||||
aria-label={t("merge.editBlock")}
|
||||
value={editing.text}
|
||||
onChange={(e) => setEditing({ i, text: e.target.value })}
|
||||
spellCheck={false}
|
||||
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
|
||||
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
|
||||
/>
|
||||
) : lines ? (
|
||||
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{lines.length ? lines.join("\n") : " "}
|
||||
</pre>
|
||||
) : (
|
||||
<>
|
||||
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{p.ours.length ? p.ours.join("\n") : " "}
|
||||
</pre>
|
||||
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{p.theirs.length ? p.theirs.join("\n") : " "}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
wide
|
||||
testId="merge-editor"
|
||||
title={t("merge.title", { path })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<ErrorLine error={error} />
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-save"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("merge.save")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!sides ? (
|
||||
!error && <p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !sides.usable || !parts ? (
|
||||
<p data-testid="merge-unusable" className="text-status-warning">
|
||||
{sides.binary
|
||||
? t("merge.binary")
|
||||
: sides.tooBig
|
||||
? t("merge.tooBig")
|
||||
: sides.working == null
|
||||
? t("merge.noWorking")
|
||||
: t("merge.unbalanced")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
data-testid="merge-left"
|
||||
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
|
||||
>
|
||||
{whole != null
|
||||
? t("merge.wholeMode")
|
||||
: left
|
||||
? t("merge.left", { count: left, total })
|
||||
: t("merge.allChosen")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("merge.prev")}
|
||||
disabled={!total || whole != null}
|
||||
onClick={() => jump(-1)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("merge.next")}
|
||||
disabled={!total || whole != null}
|
||||
onClick={() => jump(1)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<label className="ml-auto flex items-center gap-1 text-[11px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showBase}
|
||||
onChange={(e) => setShowBase(e.target.checked)}
|
||||
/>
|
||||
{t("merge.showBase")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-whole-toggle"
|
||||
className={BTN}
|
||||
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
|
||||
>
|
||||
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
|
||||
</button>
|
||||
</div>
|
||||
{showBase && (
|
||||
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
|
||||
)}
|
||||
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
|
||||
<Pane
|
||||
title={incomingLabel}
|
||||
text={sides.theirs}
|
||||
testId="merge-incoming"
|
||||
tone="incoming"
|
||||
/>
|
||||
{result}
|
||||
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file The banner a paused cherry-pick or revert shows above the Changes
|
||||
* panel (the reference tools' "sequencer" bar): "Cherry-pick paused at commit
|
||||
* X (2 of 5): resolve the conflicted files, then Continue — or Skip this
|
||||
* commit, or Abort to go back to where you were", with those three buttons.
|
||||
* Continue is disabled while a file is still conflicted; Skip and Abort throw
|
||||
* work away, so each takes a second click (`useArmed`) and sends
|
||||
* `confirm: true`. Reads GET /git/sequence; acts through git-pick.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { PickAction, PickSequence } from "../../../lib/gitHistory";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
|
||||
type Active = Extract<PickSequence, { active: true }>;
|
||||
|
||||
export function PickBanner({
|
||||
target,
|
||||
conflicts,
|
||||
reread,
|
||||
busy,
|
||||
onBusy,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** The git facts' conflict count: a change re-reads the sequence. */
|
||||
conflicts: number;
|
||||
reread: number;
|
||||
busy: boolean;
|
||||
onBusy: (busy: boolean) => void;
|
||||
/** A button ran: re-read the facts and the changes. */
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [seq, setSeq] = useState<Active | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { arm, disarm, isArmed } = useArmed();
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.sequence(target)
|
||||
.then((r) => live && setSeq(r.available && r.active ? r : null))
|
||||
.catch(() => live && setSeq(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
|
||||
}, [conflicts, reread]);
|
||||
|
||||
if (!seq) return null;
|
||||
const op = t(`op.${seq.operation}`);
|
||||
const step = seq.done + 1;
|
||||
const total = seq.done + 1 + seq.remaining.length;
|
||||
const cur = seq.current;
|
||||
const left = seq.conflicts.length;
|
||||
|
||||
const act = async (action: PickAction) => {
|
||||
disarm();
|
||||
onBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.gitHistory.act(
|
||||
target,
|
||||
action,
|
||||
action === "pick-continue" ? {} : { confirm: true }
|
||||
);
|
||||
if (action === "pick-abort") toast.success(t("aborted", { op }));
|
||||
else if (r.paused) toast.error(t("pausedConflicts", { op, count: r.conflicts.length }));
|
||||
else toast.success(t("sequenceDone", { op, count: r.commits?.length ?? 0 }));
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
onBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const btn =
|
||||
"shrink-0 rounded border px-1.5 py-0.5 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
return (
|
||||
<div
|
||||
data-testid="pick-banner"
|
||||
className="mb-2 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||||
>
|
||||
<p className="[overflow-wrap:anywhere]">
|
||||
<strong>
|
||||
{cur
|
||||
? t("pausedAt", { op, short: cur.short, subject: cur.subject, step, total })
|
||||
: t("pausedBetween", { op, step, total })}
|
||||
</strong>{" "}
|
||||
{left
|
||||
? t("pausedHelpConflicts", { count: left })
|
||||
: cur
|
||||
? t("pausedHelpReady")
|
||||
: t("pausedHelpResume")}
|
||||
</p>
|
||||
{seq.remaining.length > 0 && (
|
||||
<p className="mt-0.5 text-fg-muted [overflow-wrap:anywhere]">
|
||||
{t("stillToApply", {
|
||||
list: seq.remaining.map((c) => c.short).join(", "),
|
||||
count: seq.remaining.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-continue"
|
||||
disabled={busy || left > 0}
|
||||
title={left ? t("continueBlocked") : undefined}
|
||||
onClick={() => void act("pick-continue")}
|
||||
className={`${btn} border-accent/40 bg-accent/15 text-accent hover:bg-accent/25`}
|
||||
>
|
||||
{t("continue")}
|
||||
</button>
|
||||
{cur && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-skip"
|
||||
disabled={busy}
|
||||
title={t("skipHint")}
|
||||
onClick={() => (isArmed("skip") ? void act("pick-skip") : arm("skip"))}
|
||||
className={`${btn} border-border text-fg-secondary hover:bg-surface-2`}
|
||||
>
|
||||
{isArmed("skip") ? t("skipConfirm") : t("skip")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-abort"
|
||||
disabled={busy}
|
||||
title={t("abortHint")}
|
||||
onClick={() => (isArmed("abort") ? void act("pick-abort") : arm("abort"))}
|
||||
className={`${btn} border-status-danger/40 text-status-danger hover:bg-status-danger/10`}
|
||||
>
|
||||
{isArmed("abort") ? t("abortConfirm", { op }) : t("abort")}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-1 whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @file RebaseDialog.tsx
|
||||
* @description "Rebase current branch onto…": pick a local or fetched remote
|
||||
* branch, see the plan before anything runs (GET /git/rewrite-plan
|
||||
* kind=rebase — how many commits are replayed, how many are already pushed and
|
||||
* so will need a force-push, how far behind the branch is), then `rebase`.
|
||||
* A conflict closes the dialog: the Changes panel's banner then offers
|
||||
* Continue / Skip / Abort and the merge editor resolves the files. A clean
|
||||
* rebase stays open on its result with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
|
||||
/** Why a rewrite cannot start, from the plan's common facts — or null. */
|
||||
export function blockedReason(
|
||||
t: (k: string, o?: Record<string, unknown>) => string,
|
||||
p: { subdir: boolean; operation: string | null; dirty: boolean },
|
||||
needsClean = true
|
||||
): string | null {
|
||||
if (p.subdir) return t("blocked.subdir");
|
||||
if (p.operation) return t("blocked.operation", { op: p.operation });
|
||||
if (needsClean && p.dirty) return t("blocked.dirty");
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RebaseDialog({
|
||||
target,
|
||||
current,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
current: string | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [refs, setRefs] = useState<string[]>([]);
|
||||
const [onto, setOnto] = useState("");
|
||||
const [plan, setPlan] = useState<RebasePlan | null>(null);
|
||||
const [force, setForce] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
api.lanes
|
||||
.gitBranches(target)
|
||||
.then((b) => {
|
||||
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
|
||||
setRefs(list);
|
||||
})
|
||||
.catch((e) => setError(errText(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlan(null);
|
||||
setForce(false);
|
||||
if (!onto) return;
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planRebase(target, onto)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, onto]);
|
||||
|
||||
const blocked = plan ? blockedReason(t, plan) : null;
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase", {
|
||||
onto,
|
||||
head: plan.head,
|
||||
...(plan.pushed ? { force: true } : {}),
|
||||
});
|
||||
onChanged();
|
||||
if (r.conflicts?.length || r.stopped) {
|
||||
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success(t("rebase.done", { onto }));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("rebase.title", { branch: current ?? "HEAD" })}
|
||||
onClose={onClose}
|
||||
testId="rebase-dialog"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rebase-run"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={
|
||||
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
|
||||
}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t("rebase.run")}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : (
|
||||
<>
|
||||
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
|
||||
{t("rebase.onto")}
|
||||
</label>
|
||||
<select
|
||||
id="rebase-onto"
|
||||
data-testid="rebase-onto"
|
||||
value={onto}
|
||||
onChange={(e) => setOnto(e.target.value)}
|
||||
className="input mb-2 w-full py-1 text-[11px]"
|
||||
>
|
||||
<option value="">{t("rebase.pick")}</option>
|
||||
{refs.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
|
||||
{plan && (
|
||||
<div data-testid="rebase-plan" className="space-y-1">
|
||||
{blocked ? (
|
||||
<Warn>{blocked}</Warn>
|
||||
) : !plan.commits.length ? (
|
||||
<p>{t("rebase.nothing", { onto })}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{t("rebase.summary", {
|
||||
count: plan.commits.length,
|
||||
onto,
|
||||
behind: plan.behind,
|
||||
})}
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
|
||||
{plan.commits.map((c) => (
|
||||
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
|
||||
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
|
||||
{c.pushed && (
|
||||
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{plan.pushed > 0 && (
|
||||
<Warn testId="rebase-pushed">
|
||||
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="rebase-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
</Warn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @file ReflogView.tsx
|
||||
* @description The read-only reflog of HEAD or of one local branch, paged
|
||||
* (GET /git/reflog): each entry's selector, what moved it (commit, rebase,
|
||||
* reset, checkout…), git's message, when, and the commit it points at — a
|
||||
* click opens that commit in the Changes panel. "Reset here" hands the entry
|
||||
* to the reset dialog, which is how a lost commit is brought back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type ReflogEntry } from "../../../lib/gitRewrite";
|
||||
import { BTN, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
function when(time: number | null, lang: string): string {
|
||||
if (!time) return "";
|
||||
try {
|
||||
return new Date(time * 1000).toLocaleString(lang);
|
||||
} catch {
|
||||
return new Date(time * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export function ReflogView({
|
||||
target,
|
||||
onClose,
|
||||
onViewCommit,
|
||||
onReset,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
/** Open one commit (the panel's commit view). */
|
||||
onViewCommit?: (sha: string) => void;
|
||||
/** Open the reset dialog at this commit. */
|
||||
onReset?: (entry: ReflogEntry) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["gitTools"]);
|
||||
const [ref, setRef] = useState("HEAD");
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [entries, setEntries] = useState<ReflogEntry[] | null>(null);
|
||||
const [more, setMore] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
api.lanes
|
||||
.gitBranches(target)
|
||||
.then((b) => setBranches(b.branches ?? []))
|
||||
.catch(() => setBranches([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const page = async (skip: number) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.reflog(target, { ref, skip, limit: PAGE });
|
||||
setEntries((prev) => (skip && prev ? [...prev, ...r.entries] : r.entries));
|
||||
setMore(r.more);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
if (!skip) setEntries([]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setEntries(null);
|
||||
void page(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, ref]);
|
||||
|
||||
return (
|
||||
<GitModal title={t("reflog.title")} onClose={onClose} testId="reflog-view">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
<label className="text-[11px]" htmlFor="reflog-ref">
|
||||
{t("reflog.of")}
|
||||
</label>
|
||||
<select
|
||||
id="reflog-ref"
|
||||
data-testid="reflog-ref"
|
||||
value={ref}
|
||||
onChange={(e) => setRef(e.target.value)}
|
||||
className="input min-w-0 max-w-full flex-1 py-0.5 text-[11px]"
|
||||
>
|
||||
<option value="HEAD">HEAD</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="mb-1.5 text-[11px] text-fg-muted">{t("reflog.hint")}</p>
|
||||
<ErrorLine error={error} />
|
||||
{entries == null ? (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !entries.length ? (
|
||||
<p className="text-fg-muted">{t("reflog.empty")}</p>
|
||||
) : (
|
||||
<ul data-testid="reflog-list" className="divide-y divide-border">
|
||||
{entries.map((e) => (
|
||||
<li key={`${e.index}-${e.sha}`} className="flex flex-wrap items-start gap-1.5 py-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{e.selector}</span>
|
||||
<span className="shrink-0 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
|
||||
{e.action}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words text-[11px] text-fg-primary">
|
||||
{e.message || e.subject}
|
||||
<span className="block text-[10px] text-fg-muted">
|
||||
{when(e.time, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{onViewCommit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-[11px] text-accent hover:underline"
|
||||
title={e.subject}
|
||||
onClick={() => onViewCommit(e.sha)}
|
||||
>
|
||||
{e.short}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-[11px]" title={e.subject}>
|
||||
{e.short}
|
||||
</span>
|
||||
)}
|
||||
{onReset && (
|
||||
<button type="button" className={BTN} onClick={() => onReset(e)}>
|
||||
{t("reflog.resetHere")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{more && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reflog-more"
|
||||
disabled={busy}
|
||||
className={`${BTN} mt-2 w-full`}
|
||||
onClick={() => void page(entries?.length ?? 0)}
|
||||
>
|
||||
{t("reflog.more")}
|
||||
</button>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @file RemotesDialog.tsx
|
||||
* @description The repo's remotes: each one's URL (no password is ever
|
||||
* shown — the server strips it) and default branch; add one (name + an
|
||||
* https:// with no login, ssh:// or user@host:path URL — the server's
|
||||
* allowlist), rename one, remove one (two clicks), and delete a branch ON a
|
||||
* remote (two clicks; never its default branch nor main/master/develop —
|
||||
* the server refuses those whatever this shows).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type GitRemote } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
const PROTECTED = new Set(["main", "master", "development", "develop"]);
|
||||
|
||||
export function RemotesDialog({
|
||||
target,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [remotes, setRemotes] = useState<GitRemote[] | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [renaming, setRenaming] = useState<{ from: string; to: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
const load = useCallback(() => {
|
||||
gitRewrite
|
||||
.remotes(target)
|
||||
.then((r) => setRemotes(r.remotes))
|
||||
.catch((e) => setError(errText(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(ok);
|
||||
onChanged();
|
||||
load();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
const n = name.trim();
|
||||
const u = url.trim();
|
||||
if (
|
||||
await act(
|
||||
() => gitRewrite.run(target, "remote-add", { name: n, url: u }),
|
||||
t("remotes.added", { name: n })
|
||||
)
|
||||
) {
|
||||
setName("");
|
||||
setUrl("");
|
||||
}
|
||||
};
|
||||
const rename = async () => {
|
||||
if (!renaming) return;
|
||||
const { from, to } = renaming;
|
||||
if (
|
||||
await act(
|
||||
() => gitRewrite.run(target, "remote-rename", { name: from, to: to.trim() }),
|
||||
t("remotes.renamed", { from, to: to.trim() })
|
||||
)
|
||||
)
|
||||
setRenaming(null);
|
||||
};
|
||||
const remove = (n: string) => {
|
||||
if (!isArmed(`rm:${n}`)) return arm(`rm:${n}`);
|
||||
disarm();
|
||||
void act(
|
||||
() => gitRewrite.run(target, "remote-remove", { name: n, confirm: true }),
|
||||
t("remotes.removed", { name: n })
|
||||
);
|
||||
};
|
||||
const deleteBranch = (ref: string) => {
|
||||
if (!isArmed(`del:${ref}`)) return arm(`del:${ref}`);
|
||||
disarm();
|
||||
void act(
|
||||
() => gitRewrite.run(target, "remote-branch-delete", { branch: ref, confirm: true }),
|
||||
t("remotes.branchDeleted", { ref })
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal title={t("remotes.title")} onClose={onClose} testId="remotes-dialog">
|
||||
{remotes == null ? (
|
||||
!error && <p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !remotes.length ? (
|
||||
<p className="mb-2">{t("remotes.none")}</p>
|
||||
) : (
|
||||
<ul data-testid="remotes-list" className="mb-3 space-y-2">
|
||||
{remotes.map((r) => (
|
||||
<li key={r.name} className="rounded border border-border-light p-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{renaming?.from === r.name ? (
|
||||
<input
|
||||
aria-label={t("remotes.newName")}
|
||||
data-testid="remote-rename-input"
|
||||
value={renaming.to}
|
||||
onChange={(e) => setRenaming({ from: r.name, to: e.target.value })}
|
||||
className="input min-w-0 flex-1 py-0.5 font-mono text-[11px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold text-fg-primary">
|
||||
{r.name}
|
||||
</span>
|
||||
)}
|
||||
{renaming?.from === r.name ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={busy || !renaming.to.trim()}
|
||||
onClick={() => void rename()}
|
||||
>
|
||||
{t("remotes.rename")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setRenaming(null)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setRenaming({ from: r.name, to: r.name })}
|
||||
>
|
||||
{t("remotes.rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`remote-remove-${r.name}`}
|
||||
className={BTN_DANGER}
|
||||
disabled={busy}
|
||||
onClick={() => remove(r.name)}
|
||||
>
|
||||
{isArmed(`rm:${r.name}`) ? t("remotes.removeConfirm") : t("remotes.remove")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 break-all font-mono text-[10px] text-fg-muted">{r.url}</p>
|
||||
{r.defaultBranch && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("remotes.default", { branch: r.defaultBranch })}
|
||||
</p>
|
||||
)}
|
||||
{!!r.branches?.length && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-[11px]">
|
||||
{t("remotes.branches", { count: r.branches.length })}
|
||||
</summary>
|
||||
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto">
|
||||
{r.branches.map((b) => {
|
||||
const ref = `${r.name}/${b}`;
|
||||
const locked = PROTECTED.has(b) || b === r.defaultBranch;
|
||||
return (
|
||||
<li key={b} className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{b}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`remote-branch-delete-${ref}`}
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || locked}
|
||||
title={locked ? t("remotes.branchLocked") : undefined}
|
||||
onClick={() => deleteBranch(ref)}
|
||||
>
|
||||
{isArmed(`del:${ref}`)
|
||||
? t("remotes.deleteBranchConfirm")
|
||||
: t("remotes.deleteBranch")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void add();
|
||||
}}
|
||||
className="space-y-1.5 rounded border border-border p-1.5"
|
||||
>
|
||||
<p className="text-[11px] font-medium text-fg-primary">{t("remotes.add")}</p>
|
||||
<input
|
||||
aria-label={t("remotes.name")}
|
||||
placeholder={t("remotes.name")}
|
||||
data-testid="remote-add-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input w-full font-mono text-[11px]"
|
||||
/>
|
||||
<input
|
||||
aria-label={t("remotes.url")}
|
||||
placeholder="https://host/owner/repo.git"
|
||||
data-testid="remote-add-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="input w-full font-mono text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-fg-muted">{t("remotes.urlHint")}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="remote-add-submit"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={busy || !name.trim() || !url.trim()}
|
||||
>
|
||||
{t("remotes.addButton")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<ErrorLine error={error} />
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file ResetDialog.tsx
|
||||
* @description "Reset current branch to here": the plan first (GET
|
||||
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
|
||||
* how many of those leaving are already pushed), then one of four modes, each
|
||||
* explained: soft and mixed never touch the working tree, keep refuses to
|
||||
* overwrite a local change, and hard runs only on a clean tree with the branch
|
||||
* name typed back. Pushed commits need an explicit tick (a force-push will be
|
||||
* needed). A done reset stays open on its result with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import {
|
||||
errText,
|
||||
gitRewrite,
|
||||
type ResetMode,
|
||||
type ResetPlan,
|
||||
type RewriteResult,
|
||||
} from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
import { blockedReason } from "./RebaseDialog";
|
||||
|
||||
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
|
||||
|
||||
export function ResetDialog({
|
||||
target,
|
||||
commit,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
commit: { sha: string; short?: string; subject?: string };
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<ResetPlan | null>(null);
|
||||
const [mode, setMode] = useState<ResetMode>("mixed");
|
||||
const [force, setForce] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planReset(target, commit.sha)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, commit.sha]);
|
||||
|
||||
const branch = plan?.branch ?? "HEAD";
|
||||
const blocked = plan ? blockedReason(t, plan, false) : null;
|
||||
const hardBlocked = mode === "hard" && !!plan?.dirty;
|
||||
const ready =
|
||||
!!plan &&
|
||||
!blocked &&
|
||||
plan.related &&
|
||||
plan.head !== plan.target &&
|
||||
!(plan.pushed > 0 && !force) &&
|
||||
!hardBlocked &&
|
||||
(mode !== "hard" || typed === branch);
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "reset", {
|
||||
target: plan.target,
|
||||
mode,
|
||||
head: plan.head,
|
||||
confirm: true,
|
||||
...(plan.pushed ? { force: true } : {}),
|
||||
...(mode === "hard" ? { confirmBranch: typed } : {}),
|
||||
});
|
||||
onChanged();
|
||||
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
|
||||
onClose={onClose}
|
||||
testId="reset-dialog"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reset-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || !ready}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t("reset.run", { mode })}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : !plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{commit.subject && (
|
||||
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
|
||||
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
|
||||
{commit.subject}
|
||||
</p>
|
||||
)}
|
||||
{blocked && <Warn>{blocked}</Warn>}
|
||||
{!plan.related ? (
|
||||
<Warn>{t("reset.unrelated")}</Warn>
|
||||
) : plan.head === plan.target ? (
|
||||
<p>{t("reset.already")}</p>
|
||||
) : (
|
||||
<p data-testid="reset-summary" className="mb-1.5">
|
||||
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
|
||||
</p>
|
||||
)}
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
|
||||
{t("reset.mode")}
|
||||
</legend>
|
||||
{MODES.map((m) => (
|
||||
<label key={m} className="flex items-start gap-1.5 text-[11px]">
|
||||
<input
|
||||
type="radio"
|
||||
name="reset-mode"
|
||||
data-testid={`reset-mode-${m}`}
|
||||
value={m}
|
||||
checked={mode === m}
|
||||
onChange={() => setMode(m)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
|
||||
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{mode === "hard" &&
|
||||
(plan.dirty ? (
|
||||
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
|
||||
) : (
|
||||
<div className="mt-1.5">
|
||||
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
|
||||
{t("reset.typeBranch", { branch })}
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-branch"
|
||||
data-testid="reset-confirm-branch"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="input w-full font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{plan.pushed > 0 && (
|
||||
<Warn testId="reset-pushed">
|
||||
{t("reset.pushed", { count: plan.pushed })}
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="reset-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
</Warn>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file RewriteDone.tsx
|
||||
* @description What a finished rebase / interactive rebase / reset leaves on
|
||||
* screen: the result, a force-push reminder when the rewrite touched pushed
|
||||
* commits, and the recovery button — **Undo** = `undo-rewrite` (`reset --keep`
|
||||
* back to the recorded `before`), which the server runs only while HEAD is
|
||||
* still the `after` this dialog saw (409 ESTALE otherwise).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Undo2 } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type RewriteResult } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, ErrorLine, Warn } from "./GitModal";
|
||||
|
||||
export function RewriteDone({
|
||||
target,
|
||||
result,
|
||||
onChanged,
|
||||
onUndone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
result: RewriteResult;
|
||||
onChanged: () => void;
|
||||
onUndone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const canUndo = !!result.before && !!result.after && result.before !== result.after;
|
||||
|
||||
const undo = async () => {
|
||||
if (!isArmed("undo")) return arm("undo");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "undo-rewrite", {
|
||||
to: result.before,
|
||||
head: result.after,
|
||||
confirm: true,
|
||||
});
|
||||
toast.success(t("undone"));
|
||||
onChanged();
|
||||
onUndone();
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="git-rewrite-done" className="space-y-1.5">
|
||||
<p className="flex items-center gap-1.5 text-status-success">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
{t("doneTitle")}
|
||||
</p>
|
||||
{result.output && (
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-surface-2 p-1.5 font-mono text-[10px] text-fg-muted">
|
||||
{result.output}
|
||||
</pre>
|
||||
)}
|
||||
{result.needsForcePush && <Warn>{t("needsForcePushAfter")}</Warn>}
|
||||
{canUndo && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="git-rewrite-undo"
|
||||
disabled={busy}
|
||||
onClick={() => void undo()}
|
||||
className={`${BTN} flex items-center gap-1`}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
{isArmed("undo") ? t("undoConfirm") : t("undo")}
|
||||
</button>
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
{t("undoHint", { sha: result.before!.slice(0, 7) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file The Tags tab of the git bar's Branches panel (`BranchManager`): the
|
||||
* repo's tags (name, target commit, and for an annotated tag its message,
|
||||
* tagger and date), a form that creates one — lightweight, or annotated when a
|
||||
* message is typed — on HEAD or on a branch, tag or commit sha, and per tag:
|
||||
* new branch from it, push it to origin (one tag, never `--tags`, never
|
||||
* forced) and delete it locally, which is irreversible and so asks first. The
|
||||
* server looks every name up (server/lib/git-refs.js); nothing here decides.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Plus, Tag, Trash2, Upload } from "lucide-react";
|
||||
import type { GitTag } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../../lib/keys";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { smallBtn, smallInput, type RunRefAction } from "./refUi";
|
||||
|
||||
export function TagManager({
|
||||
tags,
|
||||
busy,
|
||||
merging,
|
||||
dirty,
|
||||
run,
|
||||
}: {
|
||||
/** Already filtered by the panel's search box. */
|
||||
tags: GitTag[];
|
||||
busy: boolean;
|
||||
merging: boolean;
|
||||
dirty: number;
|
||||
run: RunRefAction;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitRefs"]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [branchName, setBranchName] = useState<string | null>(null);
|
||||
const [switchTo, setSwitchTo] = useState(false);
|
||||
|
||||
const create = async () => {
|
||||
const tag = name.trim();
|
||||
if (!tag) return;
|
||||
const ok = await run(
|
||||
"tag-create",
|
||||
{
|
||||
tag,
|
||||
...(message.trim() ? { message: message.trim() } : {}),
|
||||
...(from.trim() ? { from: from.trim() } : {}),
|
||||
},
|
||||
t("done.tagCreate", { tag })
|
||||
);
|
||||
if (ok) {
|
||||
setName("");
|
||||
setMessage("");
|
||||
setFrom("");
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
const push = async (tag: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("tagPush"),
|
||||
message: t("tagPushConfirm", { tag }),
|
||||
confirmLabel: t("tagPush"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok) await run("tag-push", { tag }, t("done.tagPush", { tag }));
|
||||
};
|
||||
const remove = async (tag: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("tagDelete"),
|
||||
message: t("tagDeleteConfirm", { tag }),
|
||||
confirmLabel: t("tagDelete"),
|
||||
destructive: true,
|
||||
});
|
||||
if (ok && (await run("tag-delete", { tag, confirm: true }, t("done.tagDelete", { tag }))))
|
||||
setSelected(null);
|
||||
};
|
||||
const branchFrom = (tag: string) => {
|
||||
const branch = (branchName ?? "").trim();
|
||||
if (!branch) return;
|
||||
void run(
|
||||
"branch-create",
|
||||
{ branch, from: `refs/tags/${tag}`, ...(switchTo ? { switch: true } : {}) },
|
||||
t("done.create", { branch })
|
||||
).then((ok) => ok && setBranchName(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="px-2">
|
||||
{creating ? (
|
||||
<div className="space-y-1.5 rounded border border-border-light bg-surface-0/40 p-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) void create();
|
||||
}}
|
||||
placeholder={t("tagName")}
|
||||
aria-label={t("tagName")}
|
||||
className={`${smallInput} w-full`}
|
||||
/>
|
||||
<input
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
placeholder={t("tagFrom")}
|
||||
aria-label={t("tagFrom")}
|
||||
className={`${smallInput} w-full`}
|
||||
/>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t("tagMessage")}
|
||||
aria-label={t("tagMessage")}
|
||||
className={`${smallInput} w-full resize-y font-sans`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || !name.trim()}
|
||||
onClick={() => void create()}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{message.trim() ? t("tagCreateAnnotated") : t("tagCreateLight")}
|
||||
</button>
|
||||
<button type="button" className={smallBtn} onClick={() => setCreating(false)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging}
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("tagNew")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<p className="px-2 text-xs text-fg-muted">{t("noTags")}</p>
|
||||
) : (
|
||||
<ul>
|
||||
{tags.map((tag) => {
|
||||
const open = selected === tag.name;
|
||||
return (
|
||||
<li key={tag.name} className={open ? "rounded bg-surface-2" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
data-ref-row
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setSelected(open ? null : tag.name);
|
||||
setBranchName(null);
|
||||
}}
|
||||
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||
>
|
||||
<Tag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span title={tag.name} className="truncate font-mono text-xs text-fg-primary">
|
||||
{tag.name}
|
||||
</span>
|
||||
{tag.sha && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{tag.sha.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
{tag.annotated && (
|
||||
<span className="shrink-0 rounded border border-border-light px-1 text-[9px] uppercase text-fg-muted">
|
||||
{t("annotated")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-fg-muted">
|
||||
{[tag.message, tag.tagger, tag.date ? timeAgo(tag.date) : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !tag.sha}
|
||||
onClick={() => {
|
||||
setBranchName((v) => (v === null ? "" : null));
|
||||
setSwitchTo(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("newFrom")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging}
|
||||
title={t("tagPushHint")}
|
||||
onClick={() => void push(tag.name)}
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{t("tagPush")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${smallBtn} hover:text-status-danger`}
|
||||
disabled={busy || merging}
|
||||
title={t("tagDeleteHint")}
|
||||
onClick={() => void remove(tag.name)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("tagDelete")}
|
||||
</button>
|
||||
</div>
|
||||
{branchName !== null && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={branchName}
|
||||
onChange={(e) => setBranchName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) branchFrom(tag.name);
|
||||
}}
|
||||
placeholder={t("newBranchName")}
|
||||
aria-label={t("newBranchName")}
|
||||
className={smallInput}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={switchTo}
|
||||
onChange={setSwitchTo}
|
||||
disabled={dirty > 0 || merging}
|
||||
label={t("switchToIt")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !branchName.trim()}
|
||||
onClick={() => branchFrom(tag.name)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagManager;
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's history tools: the 3-way merge editor
|
||||
* (three panes, per-block accept/both/edit, "N left", Save only when every
|
||||
* block is chosen and it sends the composed result with the opened sha), the
|
||||
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
|
||||
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
|
||||
* reset dialog (mode, hard needs a clean tree and the typed branch name).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MergeEditor, composeResult } from "../MergeEditor";
|
||||
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
|
||||
import { ResetDialog } from "../ResetDialog";
|
||||
import { RebaseSkipButton } from "../GitTools";
|
||||
import { gitRewrite } from "../../../../lib/gitRewrite";
|
||||
import { parseConflicts } from "../../../../lib/conflicts";
|
||||
|
||||
vi.mock("../../../../lib/gitRewrite", async (orig) => {
|
||||
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
|
||||
return {
|
||||
...real,
|
||||
gitRewrite: {
|
||||
conflict: vi.fn(),
|
||||
planInteractive: vi.fn(),
|
||||
planReset: vi.fn(),
|
||||
run: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const WORKING = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine",
|
||||
"=======",
|
||||
"theirs",
|
||||
">>>>>>> feat",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> feat",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const HEAD = "a".repeat(40);
|
||||
const commit = (n: number, pushed = false) => ({
|
||||
sha: String(n).repeat(40),
|
||||
short: String(n).repeat(7),
|
||||
subject: `c${n}`,
|
||||
message: `c${n}`,
|
||||
pushed,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(gitRewrite.conflict)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
operation: "merge",
|
||||
branch: "main",
|
||||
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
|
||||
base: "top\nbase\n",
|
||||
ours: "top\nmine\n",
|
||||
theirs: "top\ntheirs\n",
|
||||
working: WORKING,
|
||||
sha: "s1",
|
||||
usable: true,
|
||||
binary: false,
|
||||
tooBig: false,
|
||||
});
|
||||
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
|
||||
});
|
||||
|
||||
describe("MergeEditor", () => {
|
||||
it("shows three panes and saves the composed result once every block is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
|
||||
const incoming = await screen.findByTestId("merge-incoming");
|
||||
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
|
||||
expect(incoming).toHaveTextContent("theirs");
|
||||
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
|
||||
const save = screen.getByTestId("merge-save");
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
|
||||
await user.click(within(first).getByTestId("merge-accept-incoming"));
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
|
||||
await user.click(within(second).getByTestId("merge-both-ic"));
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
|
||||
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
|
||||
path: "a.txt",
|
||||
content: "top\ntheirs\nmiddle\nb\na\n",
|
||||
baseSha: "s1",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits one block by hand, and the base pane is one click away", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
||||
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
|
||||
await user.click(within(first).getByTestId("merge-edit-block"));
|
||||
const box = within(first).getByTestId("merge-block-text");
|
||||
await user.clear(box);
|
||||
await user.type(box, "hand");
|
||||
await user.click(within(first).getByRole("button", { name: "Done" }));
|
||||
expect(first).toHaveTextContent("hand");
|
||||
expect(screen.queryByTestId("merge-base")).toBeNull();
|
||||
await user.click(screen.getByLabelText("Show base"));
|
||||
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
|
||||
});
|
||||
|
||||
it("labels the sides for a rebase and explains a refused save", async () => {
|
||||
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
|
||||
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
|
||||
operation: "rebase",
|
||||
});
|
||||
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
|
||||
Object.assign(new Error("x"), { code: "EMARKERS" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
||||
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
|
||||
"the branch you are rebasing onto"
|
||||
);
|
||||
await user.click(screen.getByTestId("merge-whole-toggle"));
|
||||
await user.click(screen.getByTestId("merge-save"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
|
||||
});
|
||||
|
||||
it("composeResult keeps markers on an unresolved block", () => {
|
||||
const parts = parseConflicts(WORKING.split("\n"))!;
|
||||
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
|
||||
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RebaseSkipButton", () => {
|
||||
it("skips the stopped commit and re-reads", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
|
||||
await user.click(screen.getByTestId("rebase-skip"));
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveRebase", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(gitRewrite.planInteractive)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
kind: "interactive",
|
||||
head: HEAD,
|
||||
branch: "feat",
|
||||
subdir: false,
|
||||
operation: null,
|
||||
dirty: false,
|
||||
base: "b".repeat(40),
|
||||
merges: false,
|
||||
commits: [commit(1, true), commit(2), commit(3)],
|
||||
pushed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
||||
action: "rebase-interactive",
|
||||
ok: true,
|
||||
output: "",
|
||||
before: HEAD,
|
||||
after: "c".repeat(40),
|
||||
conflicts: [],
|
||||
stopped: false,
|
||||
needsForcePush: true,
|
||||
});
|
||||
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
|
||||
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
|
||||
);
|
||||
const run = screen.getByTestId("interactive-run");
|
||||
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
|
||||
|
||||
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
|
||||
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
|
||||
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
|
||||
const msg = within(rows[2]!).getByTestId("interactive-message");
|
||||
await user.clear(msg);
|
||||
await user.type(msg, "new words");
|
||||
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
|
||||
expect(run).toBeDisabled();
|
||||
await user.click(screen.getByTestId("interactive-force"));
|
||||
expect(run).toBeEnabled();
|
||||
|
||||
await user.click(run);
|
||||
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
|
||||
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
|
||||
head: HEAD,
|
||||
confirm: true,
|
||||
force: true,
|
||||
todo: [
|
||||
{ sha: commit(1).sha, action: "pick" },
|
||||
{ sha: commit(3).sha, action: "reword", message: "new words" },
|
||||
{ sha: commit(2).sha, action: "squash" },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
|
||||
"Force push (with lease)"
|
||||
);
|
||||
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
|
||||
const c = commit(1);
|
||||
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
|
||||
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
|
||||
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
|
||||
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResetDialog", () => {
|
||||
const plan = (over = {}) => ({
|
||||
available: true,
|
||||
kind: "reset" as const,
|
||||
head: HEAD,
|
||||
branch: "feat",
|
||||
subdir: false,
|
||||
operation: null,
|
||||
dirty: false,
|
||||
target: "d".repeat(40),
|
||||
related: true,
|
||||
dropped: 2,
|
||||
added: 0,
|
||||
pushed: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("hard needs the branch name typed back", async () => {
|
||||
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
|
||||
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
||||
action: "reset",
|
||||
ok: true,
|
||||
output: "",
|
||||
before: HEAD,
|
||||
after: "d".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResetDialog
|
||||
target={3}
|
||||
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
|
||||
onClose={vi.fn()}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
|
||||
await user.click(screen.getByTestId("reset-mode-hard"));
|
||||
const run = screen.getByTestId("reset-run");
|
||||
expect(run).toBeDisabled();
|
||||
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
|
||||
expect(run).toBeEnabled();
|
||||
await user.click(run);
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
|
||||
target: "d".repeat(40),
|
||||
mode: "hard",
|
||||
head: HEAD,
|
||||
confirm: true,
|
||||
confirmBranch: "feat",
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
|
||||
vi.mocked(gitRewrite.planReset)
|
||||
.mockReset()
|
||||
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResetDialog
|
||||
target={3}
|
||||
commit={{ sha: "d".repeat(40) }}
|
||||
onClose={vi.fn()}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await screen.findByTestId("reset-pushed");
|
||||
const run = screen.getByTestId("reset-run");
|
||||
expect(run).toBeDisabled();
|
||||
await user.click(screen.getByTestId("reset-force"));
|
||||
expect(run).toBeEnabled(); // mixed works on a dirty tree
|
||||
await user.click(screen.getByTestId("reset-mode-hard"));
|
||||
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
|
||||
expect(run).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @file Pins the History tab's graph view: rows with their graph cell and ref
|
||||
* badges, the search box turning `author:`/`path:` into the log's filters,
|
||||
* all branches, multi-select (checkbox and Shift-click) into a cherry-pick
|
||||
* preview that lists the commits in the server's order and runs only on
|
||||
* Confirm, a paused result handed to the overview, and Undo after a clean
|
||||
* pick (asked first, sent with the from/to the pick answered). Also the pure
|
||||
* `parseSearch` helper, and the commit menu's rewrite tools (Reset branch to
|
||||
* here, Edit commits after this one) and Create tag here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HistoryView } from "../HistoryView";
|
||||
import { api } from "../../../../lib/api";
|
||||
import { confirmDialog } from "../../../../lib/confirm";
|
||||
import { parseSearch } from "../../../../lib/gitHistory";
|
||||
import type { LaneCommit } from "../../../../lib/types";
|
||||
|
||||
vi.mock("../../../../lib/confirm", () => ({
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
gitLog: vi.fn(),
|
||||
changes: vi.fn(),
|
||||
gitRefAction: vi.fn(),
|
||||
gitHistory: {
|
||||
commit: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
pickPlan: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
act: vi.fn(),
|
||||
compare: vi.fn(),
|
||||
treeAt: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const sha = (c: string) => c.repeat(40);
|
||||
const commit = (c: string, over: Partial<LaneCommit> = {}): LaneCommit => ({
|
||||
sha: sha(c),
|
||||
short: c.repeat(7),
|
||||
subject: `commit ${c}`,
|
||||
author: "Ann",
|
||||
date: "2026-10-01T00:00:00Z",
|
||||
refs: [],
|
||||
parents: [],
|
||||
badges: [],
|
||||
...over,
|
||||
});
|
||||
// other: c -> b (feature branch), main: a (HEAD)
|
||||
const LOG = [
|
||||
commit("c", { parents: [sha("b")], badges: [{ kind: "local", name: "feature" }] }),
|
||||
commit("b", { parents: [sha("a")] }),
|
||||
commit("a", {
|
||||
parents: [],
|
||||
badges: [
|
||||
{ kind: "local", name: "main", current: true },
|
||||
{ kind: "tag", name: "v1" },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const props = () => ({
|
||||
target: 7,
|
||||
branch: "main",
|
||||
branches: ["main", "feature"],
|
||||
historyRef: null,
|
||||
onHistoryRef: vi.fn(),
|
||||
reread: 0,
|
||||
disabled: false,
|
||||
headPushed: false,
|
||||
onViewCommit: vi.fn(),
|
||||
onOpenFileAtCommit: vi.fn(),
|
||||
onUndoCommit: vi.fn(),
|
||||
onResetHere: vi.fn(),
|
||||
onEditAfter: vi.fn(),
|
||||
onPaused: vi.fn(),
|
||||
onChanged: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitLog).mockReset().mockResolvedValue({ available: true, commits: LOG });
|
||||
vi.mocked(api.lanes.changes)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
range: "commit",
|
||||
base: null,
|
||||
baseRef: null,
|
||||
sha: sha("c"),
|
||||
context: 3,
|
||||
truncated: false,
|
||||
files: [],
|
||||
});
|
||||
vi.mocked(api.lanes.gitHistory.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockReset();
|
||||
vi.mocked(api.lanes.gitHistory.act).mockReset();
|
||||
vi.mocked(api.lanes.gitHistory.pickPlan)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
// The server's order: oldest first for a pick.
|
||||
commits: [...shas].reverse().map((s) => ({
|
||||
sha: s,
|
||||
short: s.slice(0, 7),
|
||||
subject: `commit ${s[0]}`,
|
||||
author: "Ann",
|
||||
date: "",
|
||||
})),
|
||||
branch: "main",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
});
|
||||
|
||||
describe("parseSearch", () => {
|
||||
it("splits author: and path: from the message text", () => {
|
||||
expect(parseSearch('fix login author:ann path:"src/a b.ts"')).toEqual({
|
||||
q: "fix login",
|
||||
author: "ann",
|
||||
path: "src/a b.ts",
|
||||
});
|
||||
expect(parseSearch(" ")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryView", () => {
|
||||
it("draws one graph cell per commit with its ref badges", async () => {
|
||||
render(<HistoryView {...props()} />);
|
||||
await screen.findByText("commit c");
|
||||
expect(screen.getAllByTestId("lane-changes-graph")).toHaveLength(3);
|
||||
const badges = screen.getAllByTestId("ref-badge");
|
||||
expect(badges.map((b) => b.textContent)).toEqual(["feature", "main", "v1"]);
|
||||
expect(badges[1]).toHaveAttribute("title", expect.stringMatching(/branch you are on/));
|
||||
});
|
||||
|
||||
it("searches with filters and reads every branch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HistoryView {...props()} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.type(screen.getByTestId("history-search"), "fix author:ann");
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 100,
|
||||
q: "fix",
|
||||
author: "ann",
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
await user.click(screen.getByRole("checkbox", { name: "All branches" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 100,
|
||||
all: true,
|
||||
q: "fix",
|
||||
author: "ann",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("multi-selects, previews the cherry-pick in order, and runs it only on Confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = { ...props(), historyRef: "feature" };
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
from: sha("a"),
|
||||
to: sha("e"),
|
||||
commits: [
|
||||
{ sha: sha("d"), short: "ddddddd", subject: "commit b" },
|
||||
{ sha: sha("e"), short: "eeeeeee", subject: "commit c" },
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.gitHistory.act).mockResolvedValue({
|
||||
action: "undo-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
|
||||
// Shift-click extends from the anchor (c) to b.
|
||||
fireEvent.click(screen.getByText("commit b"), { shiftKey: true });
|
||||
expect(screen.getByTestId("history-selection")).toHaveTextContent("2 commits selected");
|
||||
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
const plan = await screen.findByTestId("pick-plan");
|
||||
expect(
|
||||
within(plan)
|
||||
.getAllByRole("listitem")
|
||||
.map((li) => li.textContent)
|
||||
).toEqual(["1.bbbbbbbcommit b", "2.ccccccccommit c"]);
|
||||
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent("clean");
|
||||
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [sha("b"), sha("c")])
|
||||
);
|
||||
expect(p.onChanged).toHaveBeenCalled();
|
||||
|
||||
// Undo is offered, asked first, and sent with what the pick answered.
|
||||
await user.click(await screen.findByTestId("pick-undo"));
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "undo-pick", {
|
||||
from: sha("a"),
|
||||
to: sha("e"),
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("pick-result")).toBeNull());
|
||||
});
|
||||
|
||||
it("refuses to start when the tree is dirty, and hands a paused pick to the overview", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = { ...props(), historyRef: "feature" };
|
||||
vi.mocked(api.lanes.gitHistory.pickPlan).mockResolvedValueOnce({
|
||||
available: true,
|
||||
op: "cherry-pick",
|
||||
commits: [{ sha: sha("c"), short: "ccccccc", subject: "commit c", author: "", date: "" }],
|
||||
branch: "main",
|
||||
clean: false,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
await screen.findByTestId("pick-plan");
|
||||
expect(screen.getByTestId("pick-confirm")).toBeDisabled();
|
||||
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent(/uncommitted changes/);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: ["a.txt"],
|
||||
paused: true,
|
||||
});
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() => expect(p.onPaused).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("pick-result")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the details pane on a click and the full change on a double-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = props();
|
||||
render(<HistoryView {...p} />);
|
||||
await user.click(await screen.findByText("commit b"));
|
||||
expect(await screen.findByTestId("commit-details")).toBeInTheDocument();
|
||||
expect(api.lanes.gitHistory.commit).toHaveBeenCalledWith(7, sha("b"));
|
||||
await user.dblClick(screen.getByText("commit c"));
|
||||
expect(p.onViewCommit).toHaveBeenCalledWith(sha("c"));
|
||||
});
|
||||
|
||||
it("keeps the rewrite tools in the commit menu and creates a tag at a commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = props();
|
||||
vi.mocked(api.lanes.gitRefAction).mockReset().mockResolvedValue({
|
||||
action: "tag-create",
|
||||
ok: true,
|
||||
output: "",
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
const menuOn = async (text: string) => {
|
||||
await user.pointer({ keys: "[MouseRight]", target: await screen.findByText(text) });
|
||||
return screen.getByTestId("lane-changes-commit-menu");
|
||||
};
|
||||
// HEAD's own commit (main, the current branch): nothing after it to edit.
|
||||
expect(
|
||||
within(await menuOn("commit a")).getByTestId("lane-changes-menu-interactive")
|
||||
).toBeDisabled();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(within(await menuOn("commit b")).getByText("Reset branch to here…"));
|
||||
expect(p.onResetHere).toHaveBeenCalledWith(expect.objectContaining({ sha: sha("b") }));
|
||||
await user.click(within(await menuOn("commit b")).getByText("Edit commits after this one…"));
|
||||
expect(p.onEditAfter).toHaveBeenCalledWith(sha("b"));
|
||||
|
||||
await user.click(within(await menuOn("commit a")).getByText("Create tag here…"));
|
||||
await user.type(screen.getByTestId("create-ref-name"), "v2");
|
||||
await user.click(screen.getByTestId("create-ref-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(7, "tag-create", {
|
||||
tag: "v2",
|
||||
from: sha("a"),
|
||||
})
|
||||
);
|
||||
expect(p.onChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file Pins the paused cherry-pick/revert banner: it names the commit it
|
||||
* stopped at and the step ("2 of 3"), Continue waits for every conflict to be
|
||||
* resolved, Skip and Abort each need a second click and only then send
|
||||
* `confirm: true`, and it draws nothing when no pick is open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PickBanner } from "../PickBanner";
|
||||
import { api } from "../../../../lib/api";
|
||||
|
||||
vi.mock("../../../../lib/api", () => ({
|
||||
api: { lanes: { gitHistory: { sequence: vi.fn(), act: vi.fn() } } },
|
||||
}));
|
||||
|
||||
const paused = (conflicts: string[]) => ({
|
||||
available: true as const,
|
||||
active: true as const,
|
||||
operation: "cherry-pick" as const,
|
||||
current: { sha: "b".repeat(40), short: "bbbbbbb", subject: "edit a" },
|
||||
remaining: [{ sha: "c".repeat(40), short: "ccccccc", subject: "add c" }],
|
||||
done: 1,
|
||||
startedFrom: "a".repeat(40),
|
||||
conflicts,
|
||||
});
|
||||
|
||||
const props = () => ({
|
||||
target: 7,
|
||||
conflicts: 1,
|
||||
reread: 0,
|
||||
busy: false,
|
||||
onBusy: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitHistory.act).mockReset().mockResolvedValue({
|
||||
action: "pick-continue",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
commits: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("PickBanner", () => {
|
||||
it("names the stop and its step, and Continue waits for the conflicts", async () => {
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused(["a.txt"]));
|
||||
render(<PickBanner {...props()} />);
|
||||
const banner = await screen.findByTestId("pick-banner");
|
||||
expect(banner).toHaveTextContent('cherry-pick paused at bbbbbbb "edit a" (2 of 3)');
|
||||
expect(banner).toHaveTextContent("Still to apply: ccccccc");
|
||||
expect(screen.getByTestId("pick-continue")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("continues once resolved, and skips/aborts only on a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused([]));
|
||||
const p = props();
|
||||
render(<PickBanner {...p} />);
|
||||
await user.click(await screen.findByTestId("pick-continue"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "pick-continue", {})
|
||||
);
|
||||
expect(p.onDone).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("pick-skip"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByTestId("pick-skip"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-skip", { confirm: true });
|
||||
|
||||
await user.click(screen.getByTestId("pick-abort"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(2);
|
||||
await user.click(screen.getByTestId("pick-abort"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-abort", { confirm: true });
|
||||
});
|
||||
|
||||
it("draws nothing when no pick is open", async () => {
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue({ available: true, active: false });
|
||||
const { container } = render(<PickBanner {...props()} />);
|
||||
await waitFor(() => expect(api.lanes.gitHistory.sequence).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file Shared bits of the Branches panel (`BranchManager`, `TagManager`): the
|
||||
* small button and input classes, the write runner's type, and the arrow-key
|
||||
* walk over a list's rows.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { GitRefAction, GitRefBody } from "../../../lib/api";
|
||||
|
||||
/** Runs one write; resolves true when it succeeded (the list is then re-read). */
|
||||
export type RunRefAction = (
|
||||
action: GitRefAction,
|
||||
body: GitRefBody,
|
||||
done?: string
|
||||
) => Promise<boolean>;
|
||||
|
||||
export const smallBtn =
|
||||
"inline-flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const smallInput =
|
||||
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
|
||||
/** Arrow-key walk over the rows of a list (`[data-ref-row]`), Home/End too. */
|
||||
export function onListKeys(e: KeyboardEvent<HTMLElement>) {
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
|
||||
// Inside a text field the arrows and Home/End move the caret.
|
||||
if ((e.target as HTMLElement).closest("input, textarea, select")) return;
|
||||
const rows = [...e.currentTarget.querySelectorAll<HTMLElement>("[data-ref-row]")];
|
||||
if (!rows.length) return;
|
||||
const at = rows.indexOf(document.activeElement as HTMLElement);
|
||||
const next =
|
||||
e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? rows.length - 1
|
||||
: e.key === "ArrowDown"
|
||||
? Math.min(rows.length - 1, at + 1)
|
||||
: Math.max(0, at - 1);
|
||||
e.preventDefault();
|
||||
rows[next]?.focus();
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||
* and Manage opens the Claude config page on that tab.
|
||||
* and Manage opens the Claude config page on that tab. The menu opens
|
||||
* downward when the box sits too near the top to fit it above.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
|
||||
}
|
||||
|
||||
const PANEL =
|
||||
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
/** Room the menu and its tallest submenu (Project files: search + list) need. */
|
||||
const MENU_ROOM = 380;
|
||||
|
||||
/** The visible band the button sits in: the viewport, narrowed by any
|
||||
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
|
||||
function clipBand(el: HTMLElement): { top: number; bottom: number } {
|
||||
let top = 0;
|
||||
let bottom = window.innerHeight;
|
||||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||
const o = getComputedStyle(p);
|
||||
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
|
||||
const r = p.getBoundingClientRect();
|
||||
top = Math.max(top, r.top);
|
||||
bottom = Math.min(bottom, r.bottom);
|
||||
}
|
||||
}
|
||||
return { top, bottom };
|
||||
}
|
||||
|
||||
/** Open downward when there is not room above but more below. */
|
||||
function opensDown(el: HTMLElement | null): boolean {
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
const band = clipBand(el);
|
||||
const above = r.top - band.top;
|
||||
const below = band.bottom - r.bottom;
|
||||
return above < MENU_ROOM && below > above;
|
||||
}
|
||||
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
|
||||
@@ -195,6 +225,7 @@ export function ChatAddMenu({
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [down, setDown] = useState(false);
|
||||
const [sub, setSub] = useState<Sub | null>(null);
|
||||
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||
@@ -286,6 +317,8 @@ export function ChatAddMenu({
|
||||
/>
|
||||
))
|
||||
);
|
||||
// A submenu lines up with the menu's edge nearest the button.
|
||||
const edge = down ? "top-0" : "bottom-0";
|
||||
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||
|
||||
return (
|
||||
@@ -296,13 +329,17 @@ export function ChatAddMenu({
|
||||
aria-label={t("chat.add")}
|
||||
title={t("chat.add")}
|
||||
aria-expanded={open}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
onClick={() => {
|
||||
if (open) return close();
|
||||
setDown(opensDown(ref.current));
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
|
||||
<Row
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
label={t("chat.upload")}
|
||||
@@ -376,13 +413,13 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "project" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
|
||||
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sub === "context" || sub === "recent") && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{(sub === "context" ? context : recent)?.length ? (
|
||||
(sub === "context" ? context! : recent).map((it) => (
|
||||
@@ -405,7 +442,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "skills" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
@@ -417,7 +454,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "plugins" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{lists && lists.plugins.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||
@@ -443,7 +480,7 @@ export function ChatAddMenu({
|
||||
onClick={() => manage("plugins")}
|
||||
/>
|
||||
{shownPlugin && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{entries(
|
||||
shownPlugin.items,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* text file it read, wrote or named (a generated report) anywhere else through
|
||||
* `GET /api/sessions/:id/file`.
|
||||
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `FilePreview` with `modal` (an HTML page, from PaneChat) is the same view in
|
||||
* a modal over the whole screen.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
@@ -39,6 +41,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -85,6 +88,18 @@ export const extOf = (p: string) => {
|
||||
export const displayName = (p: string) =>
|
||||
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
||||
|
||||
/** A file inside the pane's folder: the git target that reads it and its path there. */
|
||||
function workspaceFile(path: string, scope: PreviewScope) {
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
|
||||
const target =
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
return { target, rel: file.slice(base.length + 1) };
|
||||
}
|
||||
|
||||
type Content =
|
||||
| { kind: "loading" }
|
||||
| { kind: "image"; src: string }
|
||||
@@ -121,8 +136,8 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
};
|
||||
}
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
||||
const inside = workspaceFile(path, scope);
|
||||
if (!inside) {
|
||||
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||
show(
|
||||
@@ -147,11 +162,7 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = file.slice(base.length + 1);
|
||||
const target =
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
const { target, rel } = inside;
|
||||
if (IMAGE_RE.test(path)) {
|
||||
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
||||
return;
|
||||
@@ -235,9 +246,28 @@ function defaultView(path: string): View {
|
||||
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
||||
}
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
function Body({
|
||||
path,
|
||||
content,
|
||||
view,
|
||||
scope,
|
||||
}: {
|
||||
path: string;
|
||||
content: Content;
|
||||
view: View;
|
||||
scope: PreviewScope;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
// A page in the workspace brings its relative CSS, scripts and images along.
|
||||
const inside = kind === "html" ? workspaceFile(path, scope) : null;
|
||||
const insideTarget = inside ? JSON.stringify(inside.target) : null;
|
||||
const page = useMemo(() => {
|
||||
if (!inside) return null;
|
||||
const { target, rel } = inside;
|
||||
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [insideTarget, inside?.rel]);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const shown: Content =
|
||||
@@ -290,6 +320,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
|
||||
);
|
||||
}
|
||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||
if (page && !shown.truncated) {
|
||||
return (
|
||||
<HtmlFrame
|
||||
key={path}
|
||||
path={page.rel}
|
||||
html={shown.text}
|
||||
fetchFile={page.fetchFile}
|
||||
title={baseName(path)}
|
||||
testId="artifact-frame"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||
}
|
||||
|
||||
@@ -317,6 +359,7 @@ export function FilePreview({
|
||||
onClose,
|
||||
className = "",
|
||||
messages,
|
||||
modal = false,
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
@@ -324,12 +367,16 @@ export function FilePreview({
|
||||
className?: string;
|
||||
/** The conversation, for the versions its Write/Edit calls left. */
|
||||
messages?: TranscriptMessage[];
|
||||
/** A modal over the whole screen (backdrop, Esc closes) instead of a panel. */
|
||||
modal?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(() => defaultView(path));
|
||||
const [full, setFull] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(modal, modalRef, onClose);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
() => (messages ? artifactVersions(messages, path) : []),
|
||||
@@ -384,7 +431,9 @@ export function FilePreview({
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={
|
||||
full
|
||||
modal
|
||||
? "flex h-[92vh] w-[96vw] max-w-7xl min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
: full
|
||||
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
|
||||
}
|
||||
@@ -482,16 +531,18 @@ export function FilePreview({
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{!modal && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -503,9 +554,35 @@ export function FilePreview({
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} content={content} view={view} />
|
||||
<Body path={path} content={content} view={view} scope={scope} />
|
||||
</section>
|
||||
);
|
||||
if (modal) {
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
// Nothing reaches the view underneath — so Esc, which useDialog's
|
||||
// document listener would never see, closes here.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={displayName(path)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{panel}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||
return full ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
* or an installed, enabled AI tool started bare on its default model, no
|
||||
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* or an installed, enabled AI tool started bare on its default model and
|
||||
* the account's Session defaults (permission mode, Codex sandbox/approval/
|
||||
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type GroupsUpdate,
|
||||
} from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** The start body of a bare tool on its default model, no prompt. Only a
|
||||
* different tool is named, as the home does: the workspace's own keeps
|
||||
* starting even once disabled. */
|
||||
/** The start body of a bare tool on its default model, no prompt, in the
|
||||
* account's Session defaults (Claude's permission mode, Codex's sandbox,
|
||||
* approval and bypass) — the same ones the workspace home's prompt box starts
|
||||
* from. Only a different tool is named, as the home does: the workspace's own
|
||||
* keeps starting even once disabled. */
|
||||
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
||||
const model = aiTools.byId(id)?.defaultModel;
|
||||
const codex = id === "codex" ? readCodexDefaults() : null;
|
||||
return {
|
||||
prompt: "",
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
|
||||
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
* of files or the used skills a list with a preview (`FileListModal`,
|
||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* (`openInChanges`) when it lies in the pane's folder. An HTML file — a card,
|
||||
* an Outputs row, a path in a reply — opens instead as the page, the same
|
||||
* preview in a modal over the whole screen. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||
* leaves them as typed; switching to the terminal pastes them into its input
|
||||
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
|
||||
@@ -582,6 +584,12 @@ export const PaneChat = memo(function PaneChat({
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
// An HTML file shown as the page, in a modal over the screen.
|
||||
const [htmlView, setHtmlView] = useState<string | null>(null);
|
||||
const show = useCallback(
|
||||
(path: string) => (/\.html?$/i.test(path) ? setHtmlView(path) : setPreview(path)),
|
||||
[]
|
||||
);
|
||||
// The side panel's width as a fraction of the pane: the dragged one, else
|
||||
// a default per content (a preview wants more room than the panel).
|
||||
const [dragged, setDragged] = useState(storedSide);
|
||||
@@ -681,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working || waiting ? 5000 : undefined,
|
||||
// The transcript growing re-reads it too: a hook that reached another
|
||||
// dashboard on the same database sends this one no agent_updated, and
|
||||
// without a poll Stop never showed for a turn the chat calls Thinking….
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
@@ -757,20 +768,21 @@ export const PaneChat = memo(function PaneChat({
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
(named: string) => {
|
||||
setPreview(resolveIn(cwd, named));
|
||||
show(resolveIn(cwd, named));
|
||||
},
|
||||
[cwd]
|
||||
[cwd, show]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
const path = resolveIn(cwd, named);
|
||||
if (/\.html?$/i.test(path)) return show(path);
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
setPreview(path);
|
||||
},
|
||||
[laneId, cwd]
|
||||
[laneId, cwd, show]
|
||||
);
|
||||
|
||||
// Before the session starts the CLI may be asking in the terminal (trust
|
||||
@@ -846,7 +858,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
@@ -984,7 +996,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenFile={show}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
@@ -1006,6 +1018,15 @@ export const PaneChat = memo(function PaneChat({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{htmlView && (
|
||||
<FilePreview
|
||||
path={htmlView}
|
||||
scope={scope}
|
||||
messages={convMessages}
|
||||
onClose={() => setHtmlView(null)}
|
||||
modal
|
||||
/>
|
||||
)}
|
||||
{list === "skills" && (
|
||||
<SkillListModal
|
||||
title={t("chat.outline.skills")}
|
||||
|
||||
@@ -765,7 +765,7 @@ export function Divider({
|
||||
onRatio,
|
||||
label,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||
* here takes the lead back (`resize` with `drive: true`).
|
||||
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
|
||||
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
|
||||
* the word-move keys xterm 5 sent (`altArrowSeq`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
@@ -63,7 +65,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import type { WebglAddon } from "@xterm/addon-webgl";
|
||||
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
@@ -94,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
|
||||
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
|
||||
* fields unminified in its build. Undefined on any other shape. */
|
||||
type AtlasPages = { pages: { version: number }[] };
|
||||
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
|
||||
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
|
||||
?._charAtlas;
|
||||
return Array.isArray(atlas?.pages) ? atlas : undefined;
|
||||
};
|
||||
/** Last version handed out by `restampAtlas`, shared by every pane. */
|
||||
let atlasStamp = 0;
|
||||
/**
|
||||
* Gives every page of the addon's glyph atlas a version no texture slot has
|
||||
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
|
||||
* its canvas on its next draw — what addon-webgl 0.20 does with globally
|
||||
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
|
||||
* pages down a slot can hold a stale page whose counter happens to match, and
|
||||
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
|
||||
* per-glyph increments a page makes before the next restamp. False when the
|
||||
* addon is not the shape this knows.
|
||||
*/
|
||||
const restampAtlas = (addon: WebglAddon): boolean => {
|
||||
const atlas = atlasOf(addon);
|
||||
if (!atlas) return false;
|
||||
for (const page of atlas.pages) {
|
||||
atlasStamp += 2 ** 24;
|
||||
page.version = atlasStamp;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
|
||||
const readFlag = (key: string) => {
|
||||
try {
|
||||
@@ -123,10 +155,10 @@ const clipboardProvider: IClipboardProvider = {
|
||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||
* awaits `loadXterm()` first) sets up synchronously. */
|
||||
const importXterm = async () => {
|
||||
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
||||
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-canvas"),
|
||||
import("@xterm/addon-webgl"),
|
||||
import("@xterm/addon-clipboard"),
|
||||
import("@xterm/addon-search"),
|
||||
import("@xterm/addon-web-links"),
|
||||
@@ -134,7 +166,7 @@ const importXterm = async () => {
|
||||
return {
|
||||
Terminal: xterm.Terminal,
|
||||
FitAddon: fit.FitAddon,
|
||||
CanvasAddon: canvas.CanvasAddon,
|
||||
WebglAddon: webgl.WebglAddon,
|
||||
SearchAddon: search.SearchAddon,
|
||||
WebLinksAddon: links.WebLinksAddon,
|
||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||
@@ -158,6 +190,25 @@ export function loadXterm(): Promise<Xterm> {
|
||||
return xtermLoading;
|
||||
}
|
||||
|
||||
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
|
||||
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
|
||||
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
|
||||
* readline's backward-word / forward-word and whose up/down stay Alt's. */
|
||||
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
return mac ? "\x1bb" : "\x1b[1;5D";
|
||||
case "ArrowRight":
|
||||
return mac ? "\x1bf" : "\x1b[1;5C";
|
||||
case "ArrowUp":
|
||||
return mac ? null : "\x1b[1;5A";
|
||||
case "ArrowDown":
|
||||
return mac ? null : "\x1b[1;5B";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||
const RETRY_MIN_MS = 1500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
@@ -369,7 +420,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
const attach = ({
|
||||
Terminal,
|
||||
FitAddon,
|
||||
CanvasAddon,
|
||||
WebglAddon,
|
||||
SearchAddon,
|
||||
WebLinksAddon,
|
||||
ClipboardWithProvider,
|
||||
@@ -379,7 +430,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||
// substitutes those specific glyphs from a different face with a
|
||||
// mismatched advance width, corrupting the cell it's drawn into even
|
||||
// under the canvas renderer (which only stops a glyph from shoving its
|
||||
// under the WebGL renderer (which only stops a glyph from shoving its
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||
@@ -425,17 +476,31 @@ export const TerminalView = memo(function TerminalView({
|
||||
// face whose advance width differs, so glyphs shove their neighbours along
|
||||
// and the row's tail spills onto the row below. The buffer is correct
|
||||
// throughout (copying the line yields clean text); only the painting drifts,
|
||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
||||
// context keeps the DOM renderer rather than losing the terminal.
|
||||
let canvas: CanvasAddon | null = null;
|
||||
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
|
||||
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
|
||||
// keeps the DOM renderer rather than losing the terminal, and so does a
|
||||
// pane whose context the browser takes back (GPU reset, or past the
|
||||
// browser's cap of live contexts with many panes open) — disposing the
|
||||
// addon hands the drawing back to the DOM renderer.
|
||||
let gpu: WebglAddon | null = null;
|
||||
try {
|
||||
canvas = new CanvasAddon();
|
||||
term.loadAddon(canvas);
|
||||
const addon = new WebglAddon();
|
||||
term.loadAddon(addon);
|
||||
gpu = addon;
|
||||
addon.onContextLoss(() => {
|
||||
addon.dispose();
|
||||
if (gpu === addon) gpu = null;
|
||||
});
|
||||
} catch {
|
||||
canvas = null;
|
||||
gpu = null;
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||
const hasBox = () =>
|
||||
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||
if (hasBox()) fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
@@ -522,6 +587,17 @@ export const TerminalView = memo(function TerminalView({
|
||||
term.input("\x1f", true);
|
||||
return false;
|
||||
}
|
||||
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
|
||||
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
|
||||
// ESC b / ESC f for left/right on macOS — the same keys tmux,
|
||||
// bash and claude's prompt have always been handed from this pane.
|
||||
const altArrow =
|
||||
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
|
||||
if (altArrow) {
|
||||
ev.preventDefault();
|
||||
term.input(altArrow, true);
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -564,7 +640,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
*/
|
||||
const sendSizeNow = (drive = false) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (term.cols < 2 || term.rows < 2) return;
|
||||
if (!hasBox() || term.cols < 2 || term.rows < 2) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||
};
|
||||
// This browser is the one in use now: back to its own size, and the
|
||||
@@ -601,7 +677,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// lane switch, and each reconnect.
|
||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
// A hidden terminal sends none: the server attaches it at the window's
|
||||
// own size, so a pane on a background tab is never resized by it.
|
||||
const size =
|
||||
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
@@ -696,6 +775,9 @@ export const TerminalView = memo(function TerminalView({
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||
fit.fit(); // a real change fires onResize, which reports it
|
||||
// A tab shown for the first time may fit to the very size xterm already
|
||||
// had (no onResize), yet tmux has never heard it: report it anyway.
|
||||
sendSize();
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(refit);
|
||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||
@@ -710,6 +792,44 @@ export const TerminalView = memo(function TerminalView({
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
|
||||
// with the same font. When it fills up it merges four pages into one
|
||||
// larger page and shifts the rest down, and a GPU texture is re-uploaded
|
||||
// only when its page's `version` differs from the uploaded one — after a
|
||||
// shift those per-page counters can coincide, so a slot keeps a stale
|
||||
// page of another size and every glyph is drawn squeezed or foreign
|
||||
// while the buffer stays correct (fixed upstream only in 0.20 betas).
|
||||
// Any change to the page list therefore restamps every page's version
|
||||
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
|
||||
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
|
||||
// but redraws only this pane, leaving its siblings slicing reused space.
|
||||
// An addon of another shape falls back to toggling the family's
|
||||
// trailing space on the next frame, which makes each renderer re-acquire
|
||||
// its atlas (as onFontLoaded does).
|
||||
let atlasFrame = 0;
|
||||
const rebuildAtlas = () => {
|
||||
if (gpu && restampAtlas(gpu)) return true;
|
||||
if (atlasFrame) return false;
|
||||
// Never inside the render that is rasterizing the merging glyph.
|
||||
atlasFrame = requestAnimationFrame(() => {
|
||||
atlasFrame = 0;
|
||||
const family = term.options.fontFamily ?? "";
|
||||
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||
});
|
||||
return false;
|
||||
};
|
||||
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
|
||||
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
|
||||
// The browser may also drop the textures without a context-loss event
|
||||
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
|
||||
// back, and repaint, since nothing else asks this pane to draw.
|
||||
const onPageShown = () => {
|
||||
if (document.visibilityState === "visible" && rebuildAtlas()) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onPageShown);
|
||||
window.addEventListener("focus", onPageShown);
|
||||
// Settings → Appearance changed the terminal's font or size: an open
|
||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||
const onFontPref = () => {
|
||||
@@ -728,13 +848,18 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
if (atlasFrame) cancelAnimationFrame(atlasFrame);
|
||||
atlasMerged?.dispose();
|
||||
atlasGrew?.dispose();
|
||||
document.removeEventListener("visibilitychange", onPageShown);
|
||||
window.removeEventListener("focus", onPageShown);
|
||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
detachIme();
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
gpu?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
|
||||
@@ -217,6 +217,17 @@ describe("the artifact view", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows a page as a modal over the screen, closed by Esc", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={onClose} modal />);
|
||||
const dialog = await screen.findByRole("dialog", { name: "page.html" });
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body);
|
||||
expect(await screen.findByTestId("artifact-frame")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("artifact-expand")).toBeNull();
|
||||
fireEvent.keyDown(screen.getByTestId("artifact-tab-code"), { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("steps back to an older version the conversation wrote", async () => {
|
||||
html(["<p>two</p>"]);
|
||||
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
|
||||
@@ -242,7 +253,9 @@ describe("the artifact view", () => {
|
||||
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||
).toContain("two");
|
||||
fireEvent.click(versions.querySelector("button")!);
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* included) as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||
* after `exit` or a policy close,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
* recolours live on a dark-theme change without re-attaching, draws through
|
||||
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
|
||||
* Alt+arrow word moves.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
render,
|
||||
cleanup,
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
|
||||
@@ -61,6 +63,9 @@ const termState = {
|
||||
};
|
||||
const onResizeHandlers: Array<() => void> = [];
|
||||
const termOptions: { theme?: unknown } = {};
|
||||
const refreshMock = vi.fn();
|
||||
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
|
||||
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
|
||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
@@ -90,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
dispose: disposeMock,
|
||||
refresh: refreshMock,
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
@@ -109,8 +115,47 @@ vi.mock("../../../lib/terminalTheme", () => ({
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@xterm/addon-canvas", () => ({
|
||||
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
||||
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||
const gpu: {
|
||||
last: {
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
lose: () => void;
|
||||
merge: () => void;
|
||||
grow: () => void;
|
||||
} | null;
|
||||
} = {
|
||||
last: null,
|
||||
};
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn().mockImplementation(() => {
|
||||
let onLoss = () => {};
|
||||
let onMerge = () => {};
|
||||
let onGrow = () => {};
|
||||
const addon = {
|
||||
__webgl: true,
|
||||
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
|
||||
dispose: vi.fn(),
|
||||
onContextLoss: (fn: () => void) => {
|
||||
onLoss = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onRemoveTextureAtlasCanvas: (fn: () => void) => {
|
||||
onMerge = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onAddTextureAtlasCanvas: (fn: () => void) => {
|
||||
onGrow = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
};
|
||||
gpu.last = {
|
||||
dispose: addon.dispose,
|
||||
lose: () => onLoss(),
|
||||
merge: () => onMerge(),
|
||||
grow: () => onGrow(),
|
||||
};
|
||||
return addon;
|
||||
}),
|
||||
}));
|
||||
|
||||
class MockWebSocket {
|
||||
@@ -136,6 +181,17 @@ class MockWebSocket {
|
||||
// @ts-expect-error test override
|
||||
global.WebSocket = MockWebSocket;
|
||||
|
||||
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
|
||||
* tab); returns the restore. */
|
||||
function withBox(width = 800, height = 600) {
|
||||
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
|
||||
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
|
||||
return () => {
|
||||
w.mockRestore();
|
||||
h.mockRestore();
|
||||
};
|
||||
}
|
||||
|
||||
describe("TerminalView", () => {
|
||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||
beforeAll(() => loadXterm());
|
||||
@@ -167,11 +223,51 @@ describe("TerminalView", () => {
|
||||
// it: tmux resizes the window to the newest client, so attaching at the
|
||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||
// leaves that width in the scrollback (issue #33).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||
// that size resized the real tmux pane under every other client.
|
||||
let observed: (() => void) | null = null;
|
||||
const RO = globalThis.ResizeObserver;
|
||||
// @ts-expect-error test override
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(cb: () => void) {
|
||||
observed = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
vi.useFakeTimers();
|
||||
let restore = () => {};
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toEqual([]);
|
||||
// The tab is shown: the box gets a size and its size reaches tmux.
|
||||
restore = withBox();
|
||||
observed!();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(ws.sent).toEqual([
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
vi.useRealTimers();
|
||||
globalThis.ResizeObserver = RO;
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||
@@ -397,34 +493,119 @@ describe("TerminalView", () => {
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
||||
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
|
||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
// fallback face with a different advance, so glyphs shove their neighbours
|
||||
// and the row's tail spills onto the row below — buffer clean, painting
|
||||
// wrong. The canvas renderer draws each cell at a fixed position instead.
|
||||
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||
// (xterm 6 removed the canvas one).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const addon = gpu.last!;
|
||||
expect(addon.dispose).not.toHaveBeenCalled();
|
||||
addon.lose();
|
||||
expect(addon.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||
});
|
||||
|
||||
it("restamps every atlas page at once when the page list changes", () => {
|
||||
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
|
||||
// pages a texture slot can keep a stale page whose counter matches. Every
|
||||
// page gets a version no slot has uploaded — in every pane on the atlas.
|
||||
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
|
||||
nextAtlas.pages = pages;
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame");
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
gpu.last!.merge();
|
||||
const first = pages.map((p) => p.version);
|
||||
expect(new Set(first).size).toBe(3);
|
||||
expect(first.every((v) => v > 7)).toBe(true);
|
||||
gpu.last!.grow();
|
||||
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
|
||||
true
|
||||
);
|
||||
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
|
||||
// The page coming back re-uploads and repaints this pane.
|
||||
refreshMock.mockClear();
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
expect(refreshMock).toHaveBeenCalledWith(0, 27);
|
||||
} finally {
|
||||
nextAtlas.pages = null;
|
||||
raf.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
|
||||
// An addon whose atlas this cannot reach: any option change makes each
|
||||
// renderer re-acquire its atlas and re-upload every page.
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const opts = termOptions as { fontFamily?: string };
|
||||
opts.fontFamily = "JetBrains Mono";
|
||||
gpu.last!.merge();
|
||||
gpu.last!.merge(); // coalesced into one frame
|
||||
expect(raf).toHaveBeenCalledTimes(1);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
|
||||
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono ");
|
||||
raf.mockRestore();
|
||||
});
|
||||
|
||||
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
|
||||
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
|
||||
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
|
||||
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
|
||||
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
|
||||
expect(altArrowSeq("ArrowUp", true)).toBeNull();
|
||||
expect(altArrowSeq("a", false)).toBeNull();
|
||||
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
|
||||
expect(handler(ev)).toBe(false);
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
|
||||
// Another modifier with it is xterm's own sequence.
|
||||
expect(
|
||||
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||
// next row's left margin, and tmux never repaints a row it believes
|
||||
// unchanged — the stray characters glued to the left edge after scrolling.
|
||||
it("reports its size when the socket opens", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toContain(
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||
);
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toContain(
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||
// Webfont load and page zoom both move cols without resizing the element
|
||||
// ResizeObserver watches, which is why onResize is the hook.
|
||||
const restore = withBox();
|
||||
onTestFinished(restore);
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Default badge or Set default, and Update whenever the catalog names an
|
||||
* updater — no version lookup, the updater itself knows whether it has work.
|
||||
* The ones available to install fold behind a disclosure, with Install where
|
||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
||||
* the catalog knows how (an admin's; Update all is anyone's — auth.js
|
||||
* MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* A right-click on an installed row opens its Update / Set default /
|
||||
@@ -231,7 +232,8 @@ export function AiToolsSettings({
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && updateAllTools.length > 0 && (
|
||||
{/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */}
|
||||
{updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
|
||||
@@ -41,32 +41,9 @@ const BTN =
|
||||
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
|
||||
// The regions AWS CodeCommit runs in.
|
||||
const AWS_REGIONS = [
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
"ca-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"sa-east-1",
|
||||
"me-south-1",
|
||||
"af-south-1",
|
||||
"il-central-1",
|
||||
];
|
||||
// The host a key ID is saved under when no URL names one: AWS takes the same
|
||||
// key ID in every region, so the server writes the default login for all.
|
||||
const AWS_DEFAULT_HOST = "git-codecommit.us-east-1.amazonaws.com";
|
||||
|
||||
function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||
return (
|
||||
@@ -79,14 +56,6 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||
|
||||
@@ -174,28 +143,25 @@ function KeyCard({
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||
* default key's login is the region's plain host; a named key's is saved
|
||||
* under an account name (its own name unless another is typed), tied to it.
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it — nothing else. The
|
||||
* default key's login is saved for every region; a named key's under its own
|
||||
* name, tied to it. A clone of any CodeCommit URL tries each saved login.
|
||||
*/
|
||||
function AwsLoginForm({
|
||||
busy,
|
||||
named,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
/** The named key's name; undefined for the default key. */
|
||||
named?: string;
|
||||
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||
onSave: (v: { keyId: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||
const [v, setV] = useState({ keyId: "" });
|
||||
return (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||
className="mt-1 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||
void onSave(v).then((ok) => ok && setV({ keyId: "" }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -206,27 +172,6 @@ function AwsLoginForm({
|
||||
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={v.region}
|
||||
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={v.alias}
|
||||
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
@@ -421,28 +366,41 @@ export function GitIdentitySettings() {
|
||||
const namedIds = new Set(named.map((k) => k.id));
|
||||
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||
const saveAws = (v: { keyId: string }, k?: Key) => {
|
||||
let user = v.keyId.trim();
|
||||
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||
let host = AWS_DEFAULT_HOST;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL: its key ID. The
|
||||
// region does not matter — the key ID logs in to every one.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
if (CODECOMMIT_RE.test(m[2].toLowerCase())) host = m[2].toLowerCase();
|
||||
}
|
||||
// A named key's account goes by the key's name unless another is typed.
|
||||
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||
return run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(k?.id ? { key: k.id } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
k?.name
|
||||
? // A named key (another AWS account): its login goes by the key's
|
||||
// name; a clone of any CodeCommit URL tries it.
|
||||
api.gitIdentity.setSshUser({ host, user, alias: k.name, key: k.id! })
|
||||
: api.gitIdentity.setSshUser({ host, user, allRegions: true }),
|
||||
k?.name ? t("gitIdentity.awsSavedNamed", { name: k.name }) : t("gitIdentity.awsSavedAll")
|
||||
);
|
||||
};
|
||||
// The default key's logins, one row per key ID: "every region" once the
|
||||
// server's whole list is covered, else the regions saved (an older save) —
|
||||
// with a button that extends it to every region.
|
||||
const regionCount = id?.codecommitRegions?.length ?? 0;
|
||||
const awsDefaultGroups = (rows: typeof awsRows) => {
|
||||
const out: { user: string; key: string | null; hosts: string[]; regions: string[] }[] = [];
|
||||
for (const r of rows.filter((x) => !x.alias)) {
|
||||
const g = out.find((o) => o.user === r.user && o.key === (r.key ?? null));
|
||||
if (g) {
|
||||
g.hosts.push(r.host);
|
||||
g.regions.push(r.region);
|
||||
} else out.push({ user: r.user, key: r.key ?? null, hosts: [r.host], regions: [r.region] });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const saveGitHost = (host: string, k: Key) => {
|
||||
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||
return run(
|
||||
@@ -642,10 +600,6 @@ export function GitIdentitySettings() {
|
||||
onRemove={() => removeKey("ed25519")}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
{namedOf("ed25519").map((k) => {
|
||||
const rows = rowsOf(otherRows, k);
|
||||
@@ -668,11 +622,6 @@ export function GitIdentitySettings() {
|
||||
{id.writable && rows.length === 0 && (
|
||||
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
||||
</Code>
|
||||
))}
|
||||
</Step>
|
||||
</KeyCard>
|
||||
);
|
||||
@@ -706,30 +655,72 @@ export function GitIdentitySettings() {
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{loginRows(
|
||||
rows,
|
||||
(r) =>
|
||||
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||
rows.filter((r) => !!r.alias),
|
||||
(r) => `${r.alias} · ${r.user} · ${keyLabel(r.key)}`
|
||||
)}
|
||||
{awsDefaultGroups(rows).map((g) => {
|
||||
const all = regionCount > 0 && g.regions.length >= regionCount;
|
||||
return (
|
||||
<div
|
||||
key={`${g.user}\0${g.key ?? ""}`}
|
||||
className="mt-1 flex items-center gap-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{all ? t("gitIdentity.awsAllRegions") : g.regions.join(", ")}
|
||||
{` · ${g.user} · ${keyLabel(g.key)}`}
|
||||
</span>
|
||||
{id.writable && !all && regionCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host: g.hosts[0]!,
|
||||
user: g.user,
|
||||
allRegions: true,
|
||||
}),
|
||||
t("gitIdentity.awsSavedAll")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("gitIdentity.awsUseAll")}
|
||||
</button>
|
||||
)}
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${g.user}`}
|
||||
onClick={() =>
|
||||
void run(() =>
|
||||
g.hosts.length > 1
|
||||
? api.gitIdentity.removeSshUser(g.hosts[0]!, {
|
||||
allRegions: true,
|
||||
user: g.user,
|
||||
})
|
||||
: api.gitIdentity.removeSshUser(g.hosts[0]!)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{id.writable && (
|
||||
<AwsLoginForm
|
||||
busy={busy}
|
||||
named={isNamed ? k!.name! : undefined}
|
||||
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @file NineRouterUpdate.tsx
|
||||
* @description **Update 9router** on the AI Proxy page (every user's; beside
|
||||
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||
* again hidden (tray, no browser) — the last only when it was running before.
|
||||
* The dialog shows why an update cannot run right now (the server's `blocker`,
|
||||
* with the command to run by hand when sudo needs a password) and follows the
|
||||
* job by polling until it ends. Closing the dialog does not stop the job.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
|
||||
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
|
||||
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
|
||||
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
|
||||
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
|
||||
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
|
||||
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||
}
|
||||
|
||||
export function NineRouterUpdate() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, dialogRef, () => setOpen(false));
|
||||
|
||||
const load = useCallback(async (fresh = false) => {
|
||||
try {
|
||||
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const running = status?.job?.status === "running";
|
||||
// Follow a running job — also one started from another tab.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => void load(), POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, load]);
|
||||
|
||||
const openDialog = () => {
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
void load(true);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await api.aiProxies.nineRouterUpdate();
|
||||
setStatus((s) => (s ? { ...s, job } : s));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
void load(true);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status?.installed) return null;
|
||||
const job = status.job;
|
||||
const blocker = status.blocker;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("aiProxy.update9router")}
|
||||
{status.updateAvailable && !running && (
|
||||
<span
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
|
||||
aria-label={t("aiProxy.update9routerAvailable")}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="nine-router-update-title"
|
||||
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<h3
|
||||
id="nine-router-update-title"
|
||||
className="mb-3 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{t("aiProxy.update9router")}
|
||||
</h3>
|
||||
|
||||
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
|
||||
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
|
||||
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
|
||||
<dd className="text-fg-primary">
|
||||
{status.running
|
||||
? status.managedBy === "systemd"
|
||||
? t("aiProxy.update9routerRunningUnit")
|
||||
: t("aiProxy.update9routerRunning")
|
||||
: t("aiProxy.update9routerStopped")}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p className="mb-3 text-[11px] text-fg-muted">
|
||||
{status.running
|
||||
? t("aiProxy.update9routerExplain")
|
||||
: t("aiProxy.update9routerExplainStopped")}
|
||||
</p>
|
||||
|
||||
{job && (
|
||||
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
|
||||
{job.steps.map((s) => (
|
||||
<li key={s.id} className="flex items-start gap-2 text-xs">
|
||||
<span className="mt-0.5">
|
||||
<StepIcon status={s.status} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="text-fg-primary">
|
||||
{t(`aiProxy.update9routerStep.${s.id}`)}
|
||||
</span>
|
||||
{s.message && (
|
||||
<span className="block break-words text-[11px] text-fg-muted">
|
||||
{s.message}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{job && job.status !== "running" && (
|
||||
<p
|
||||
className={`mb-3 text-xs ${
|
||||
job.status === "done" ? "text-status-success" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{job.status !== "done"
|
||||
? t("aiProxy.update9routerFailed")
|
||||
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
|
||||
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
|
||||
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
|
||||
</p>
|
||||
)}
|
||||
{job?.log && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-[11px] text-fg-muted">
|
||||
{t("aiProxy.update9routerLog")}
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
|
||||
{job.log}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{blocker && !running && blocker.code !== "EUPTODATE" && (
|
||||
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
|
||||
<p>{blocker.message}</p>
|
||||
{blocker.command && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
|
||||
{blocker.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(blocker.command!)}
|
||||
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
|
||||
aria-label={t("aiProxy.update9routerCopy")}
|
||||
title={t("aiProxy.update9routerCopy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{blocker?.code === "EUPTODATE" && !running && (
|
||||
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("aiProxy.update9routerClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={Boolean(blocker) || running || starting}
|
||||
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t("aiProxy.update9routerStart")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
||||
* mode, every user picks which account of the shared account pool their own
|
||||
* new sessions run on, per tool, among the accounts their pool group may use
|
||||
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
||||
* or when the pool holds no account the user may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
||||
|
||||
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
||||
|
||||
export function PoolAccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { local } = usePermissions();
|
||||
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
||||
const [busy, setBusy] = useState<PoolTool | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => setTools(null));
|
||||
}, [local]);
|
||||
|
||||
if (local || !tools?.length) return null;
|
||||
|
||||
const choose = async (tool: PoolTool, account: string | null) => {
|
||||
setBusy(tool);
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(tool, account);
|
||||
setTools((list) =>
|
||||
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
||||
);
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="pool-account"
|
||||
title={t("poolAccount.title")}
|
||||
description={t("poolAccount.description")}
|
||||
>
|
||||
{tools.map((x) => (
|
||||
<SettingsRow
|
||||
key={x.tool}
|
||||
id={`pool-account-${x.tool}`}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
||||
{LABEL[x.tool]}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
x.pin ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: x.pin })}
|
||||
</span>
|
||||
) : (
|
||||
t("poolAccount.count", { count: x.accounts.length })
|
||||
)
|
||||
}
|
||||
disabled={!!x.pin}
|
||||
control={
|
||||
<select
|
||||
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
||||
value={x.pin ?? x.choice ?? ""}
|
||||
disabled={!!x.pin || busy === x.tool}
|
||||
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
||||
className="select !py-1 !text-xs max-w-[16rem]"
|
||||
>
|
||||
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
||||
{x.accounts.map((a) => (
|
||||
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
||||
{[
|
||||
a.name,
|
||||
a.email && a.email !== a.name ? `(${a.email})` : null,
|
||||
a.expired
|
||||
? `— ${t("poolAccount.expired")}`
|
||||
: a.full
|
||||
? `— ${t("poolAccount.full")}`
|
||||
: a.quotaPercent != null
|
||||
? `— ${Math.round(a.quotaPercent)}%`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -262,11 +262,11 @@ function LabelEdit({
|
||||
function useRunner(load: () => Promise<unknown>) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(success);
|
||||
const r = await fn();
|
||||
toast.success(typeof success === "function" ? success(r) : success);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err);
|
||||
@@ -399,7 +399,14 @@ function OtherProfiles({
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.toolProfiles.activate(tool, p.name),
|
||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||
(r) =>
|
||||
t(
|
||||
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
|
||||
{
|
||||
name: p.label,
|
||||
tool: label,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
className={btnCls}
|
||||
@@ -800,9 +807,7 @@ function ProfileUsageLine({
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
@@ -139,28 +139,64 @@ describe("GitIdentitySettings", () => {
|
||||
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
|
||||
});
|
||||
|
||||
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
|
||||
it("saves the CodeCommit SSH key ID for every region — no region to pick", async () => {
|
||||
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
|
||||
const H = "git-codecommit.eu-west-1.amazonaws.com";
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
|
||||
const REGIONS = ["us-east-1", "eu-west-1", "ap-northeast-1"];
|
||||
const host = (r: string) => `git-codecommit.${r}.amazonaws.com`;
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
|
||||
view({
|
||||
sshKeys: [RSA],
|
||||
sshUsers: REGIONS.map((r) => ({ host: host(r), user: "APKA1" })),
|
||||
codecommitRegions: REGIONS,
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(aws).queryByLabelText("Region")).toBeNull();
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKA1",
|
||||
allRegions: true,
|
||||
});
|
||||
// One row for the key ID, not one per region.
|
||||
expect(await within(aws).findByText("Every region · APKA1 · Any key")).toBeInTheDocument();
|
||||
// A CodeCommit host never shows up again under "other hosts".
|
||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
|
||||
expect(screen.queryByText(`APKA1@${host("eu-west-1")}`)).toBeNull();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Delete APKA1" }));
|
||||
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(host("us-east-1"), {
|
||||
allRegions: true,
|
||||
user: "APKA1",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers an older one-region CodeCommit login for every region", async () => {
|
||||
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
|
||||
const H = "git-codecommit.ap-northeast-1.amazonaws.com";
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA],
|
||||
sshUsers: [{ host: H, user: "APKA9" }],
|
||||
codecommitRegions: ["us-east-1", "ap-northeast-1"],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(view({ sshKeys: [RSA] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(await within(aws).findByText("ap-northeast-1 · APKA9 · Any key")).toBeInTheDocument();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Use in every region" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA9",
|
||||
allRegions: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves a user for any other SSH host under the advanced section", async () => {
|
||||
@@ -248,19 +284,16 @@ describe("GitIdentitySettings", () => {
|
||||
// Another key can be added below it.
|
||||
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||
|
||||
// Its own step 3: the account name defaults to the key's name.
|
||||
// Its own step 3: only the key ID; the login goes by the key's name.
|
||||
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKA2",
|
||||
alias: "aws-2",
|
||||
key: "id_rsa_aws-2",
|
||||
});
|
||||
expect(
|
||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||
).toBeInTheDocument();
|
||||
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
||||
expect(await within(prod).findByText("aws-2 · APKA2 · aws-2")).toBeInTheDocument();
|
||||
// The default key's card does not list the named key's login.
|
||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||
@@ -300,8 +333,6 @@ describe("GitIdentitySettings", () => {
|
||||
alias: "git-2",
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
||||
).toBeInTheDocument();
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
|
||||
* 9router, a dialog that shows versions and starts the job, the server's
|
||||
* blocker (with the manual command) disabling the start, and the job's steps.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NineRouterUpdate } from "../NineRouterUpdate";
|
||||
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
|
||||
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
|
||||
|
||||
const base: NineRouterUpdateStatus = {
|
||||
installed: true,
|
||||
version: "0.5.104",
|
||||
latest: "0.5.109",
|
||||
updateAvailable: true,
|
||||
running: true,
|
||||
managedBy: "systemd",
|
||||
blocker: null,
|
||||
job: null,
|
||||
};
|
||||
|
||||
const job: NineRouterUpdateJob = {
|
||||
status: "running",
|
||||
startedAt: "2026-10-26T00:00:00Z",
|
||||
finishedAt: null,
|
||||
from: "0.5.104",
|
||||
to: null,
|
||||
managedBy: "systemd",
|
||||
install: "direct",
|
||||
steps: [
|
||||
{ id: "shutdown", status: "done", message: null },
|
||||
{ id: "update", status: "running", message: null },
|
||||
{ id: "relaunch", status: "pending", message: null },
|
||||
],
|
||||
log: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
statusMock.mockReset();
|
||||
startMock.mockReset();
|
||||
});
|
||||
|
||||
describe("NineRouterUpdate", () => {
|
||||
it("renders nothing when 9router is not installed", async () => {
|
||||
statusMock.mockResolvedValue({ ...base, installed: false });
|
||||
const { container } = render(<NineRouterUpdate />);
|
||||
await waitFor(() => expect(statusMock).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("opens with both versions and starts the three-step job", async () => {
|
||||
statusMock.mockResolvedValue(base);
|
||||
startMock.mockResolvedValue(job);
|
||||
render(<NineRouterUpdate />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||
expect(statusMock).toHaveBeenLastCalledWith(true);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("0.5.104");
|
||||
expect(dialog).toHaveTextContent("0.5.109");
|
||||
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
|
||||
expect(startMock).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
|
||||
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the blocker with its manual command and keeps the start disabled", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...base,
|
||||
blocker: {
|
||||
code: "ENOPERM",
|
||||
message: "/usr/local is not writable by this account and sudo needs a password",
|
||||
command: "sudo npm install -g 9router@latest",
|
||||
},
|
||||
});
|
||||
render(<NineRouterUpdate />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
||||
* hidden in local mode and with no usable account; one select per tool that
|
||||
* starts on the user's choice (or Automatic) and saves a pick through
|
||||
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
import { PoolAccountSettings } from "../PoolAccountSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
|
||||
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
||||
}));
|
||||
|
||||
const account = (name: string) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email: `${name}@x.io`,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
async function show() {
|
||||
render(<PoolAccountSettings />);
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockReset();
|
||||
m.choosePoolAccount.mockReset();
|
||||
});
|
||||
|
||||
describe("PoolAccountSettings", () => {
|
||||
it("renders nothing in local mode or with no account to choose", async () => {
|
||||
perms.local = true;
|
||||
await show();
|
||||
expect(m.poolAccounts).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts on the user's choice and saves a new pick", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.value).toBe("two");
|
||||
await userEvent.selectOptions(select, "one");
|
||||
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
await userEvent.selectOptions(select, "");
|
||||
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
});
|
||||
|
||||
it("is locked on an admin's pin", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(select.value).toBe("one");
|
||||
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`), one
|
||||
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
||||
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
||||
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
||||
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
||||
* Oh My Pi has no pool but can share the host's login (the same switch as
|
||||
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
||||
* other installed tool says that its users sign in themselves. A tab shows
|
||||
* how many accounts its pool holds, and an empty pool says so instead of
|
||||
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
||||
* @description The Users page's account pool card (`GET /api/accounts`).
|
||||
* Claude Code, Codex and Cursor accounts live on the POOLS board
|
||||
* (`PoolGroups`): one column per pool with its accounts of every tool — each
|
||||
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
|
||||
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
|
||||
* tools with no pool get a tab each below it: Oh My Pi can share the host's
|
||||
* login (the same switch as Settings → AI tools,
|
||||
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
|
||||
* says that its users sign in themselves. An admin adds an account from the
|
||||
* header's Add account, picking its tool — for Claude a
|
||||
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||
* `claude setup-token` token; for Codex a saved Codex profile
|
||||
@@ -17,11 +17,11 @@
|
||||
* Cursor API key — or removes one, each time with their own password again;
|
||||
* an analyst only reads. The secret only goes IN: the server never sends it
|
||||
* back. An account's Edit (pencil) picks the users who run on it
|
||||
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
|
||||
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
|
||||
* or "no group"): their sessions skip the balancer, and a
|
||||
* Claude login goes into their home at once — no restart. A profile's
|
||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||
* which opens that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* which opens Add on that profile; the header links back to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||
import { PoolGroups, groupsOf } from "./PoolGroups";
|
||||
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
|
||||
import { MemberPicker } from "./MemberPicker";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -69,18 +69,20 @@ type Row = {
|
||||
full: boolean;
|
||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||
windows: QuotaWindow[];
|
||||
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
|
||||
plan?: string | null;
|
||||
};
|
||||
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
|
||||
/**
|
||||
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||
*/
|
||||
function windowsOf(
|
||||
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||
export function windowsOf(
|
||||
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
|
||||
): QuotaWindow[] {
|
||||
const q = a.quota as
|
||||
| {
|
||||
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
|
||||
|
||||
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||
/** Fixed tabs, always shown; any other installed tool follows them. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
{ id: "omp", label: "Oh My Pi" },
|
||||
];
|
||||
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
|
||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||
|
||||
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const profile = params.get("poolProfile");
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
const [tab, setTab] = useState<string>("omp");
|
||||
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||
isAdmin && preset ? preset : null
|
||||
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.map((x) => ({ id: x.id, label: x.label })),
|
||||
];
|
||||
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||
const badge = (id: string): string | null => {
|
||||
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
||||
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
return null;
|
||||
};
|
||||
const badge = (id: string): string | null =>
|
||||
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
const rowOf = (tool: PoolTool, name: string) =>
|
||||
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
|
||||
|
||||
return (
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<label className="relative">
|
||||
<span className="btn-primary pointer-events-none text-xs">
|
||||
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
|
||||
</span>
|
||||
<select
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
|
||||
}}
|
||||
aria-label={t("pool.addAccount")}
|
||||
data-testid="pool-add-account"
|
||||
>
|
||||
<option value="">{t("pool.addAccount")}</option>
|
||||
{POOL_TOOL_LABELS.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && (
|
||||
<PoolGroups
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={() => void load()}
|
||||
renderAccount={(tool, name) => {
|
||||
const r = rowOf(tool, name);
|
||||
return r ? (
|
||||
<AccountCard
|
||||
row={r}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => setEditing(r)}
|
||||
onRemove={() => setRemoving(r)}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.otherTools")}
|
||||
</h3>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("pool.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
aria-label={t("pool.otherTools")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
|
||||
>
|
||||
{tabs.map((x) => {
|
||||
const selected = x.id === current.id;
|
||||
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||
{isPoolTool(current.id) ? (
|
||||
<PoolPanel
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||
onEdit={setEditing}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
) : current.id === "omp" ? (
|
||||
{current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
@@ -351,89 +380,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||
function PoolPanel({
|
||||
tool,
|
||||
label,
|
||||
pool,
|
||||
isAdmin,
|
||||
installed,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
pool: AccountPool | null;
|
||||
isAdmin: boolean;
|
||||
installed: boolean;
|
||||
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||
onAdd: (profile?: string) => void;
|
||||
onEdit: (r: Row) => void;
|
||||
onRemove: (r: Row) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const rows = pool ? rowsOf(pool, tool) : [];
|
||||
const grouped = (pool?.groups ?? []).length > 0;
|
||||
const addButton = isAdmin && (
|
||||
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||
{t(`pool.about.${tool}`)}
|
||||
{!installed && (
|
||||
<span className="ml-1 text-status-warning">
|
||||
{t("pool.notInstalled", { tool: label })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 && addButton}
|
||||
</div>
|
||||
{pool && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||
{addButton}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<AccountCard
|
||||
key={r.name}
|
||||
row={r}
|
||||
pool={pool!}
|
||||
grouped={grouped}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => onEdit(r)}
|
||||
onRemove={() => onRemove(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
@@ -443,39 +399,52 @@ function AccountCard({
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
|
||||
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||
}`}
|
||||
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
data-testid="pool-account"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
|
||||
{r.plan && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
|
||||
data-testid="pool-account-plan"
|
||||
>
|
||||
{r.plan}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
|
||||
</div>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
|
||||
{t(`pool.kindShort.${r.kind}`)}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -488,68 +457,47 @@ function AccountCard({
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{metered && (
|
||||
<div className="space-y-1" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
|
||||
<span className="inline-flex items-center gap-1 text-fg-secondary">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("pool.liveCount", { count: r.livePanes })}
|
||||
</span>
|
||||
{metered && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
<span className="basis-full">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{metered && (
|
||||
<div className="space-y-1.5" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metered && (
|
||||
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||
<div>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</div>
|
||||
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||
</div>
|
||||
)}
|
||||
{grouped && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{inGroups.length === 0 ? (
|
||||
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||
) : (
|
||||
inGroups.map((g) => (
|
||||
<span key={g.id} className="badge py-0 text-[10px]">
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<UserRound className="h-3 w-3" aria-hidden />
|
||||
{t("pool.pinned", { users: users.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -558,7 +506,7 @@ function AccountCard({
|
||||
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||
* 0% = used up), with when it resets.
|
||||
*/
|
||||
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
const { t } = useTranslation("users");
|
||||
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||
const resets = untilText(w.resetsAt);
|
||||
@@ -568,7 +516,7 @@ function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${
|
||||
className={`block h-full transition-[width] duration-500 ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
@@ -596,72 +544,6 @@ function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||
* leaves the switcher, since a login's refresh token may have one holder.
|
||||
*/
|
||||
function SavedProfiles({
|
||||
tool,
|
||||
pool,
|
||||
onShare,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||
pool: AccountPool | null;
|
||||
onShare: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool, pool]);
|
||||
return (
|
||||
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.savedProfiles.title")}
|
||||
</h3>
|
||||
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{profiles !== null && profiles.length === 0 && (
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||
)}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">
|
||||
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShare(p.name)}
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||
@@ -777,13 +659,8 @@ function EditAccountDialog({
|
||||
|
||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||
const ready = changed && current.length > 0;
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setChosen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const groupOf = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
@@ -822,38 +699,28 @@ function EditAccountDialog({
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users?.map((u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => toggle(u.id, on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||
)}
|
||||
{elsewhere && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.onAccount", { account: elsewhere.account })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1">
|
||||
<MemberPicker
|
||||
users={users}
|
||||
chosen={chosen}
|
||||
onChange={setChosen}
|
||||
groupOf={groupOf}
|
||||
grouped={(pool.groups ?? []).length > 0}
|
||||
note={(u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
const group = groupOf(u.id);
|
||||
return (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{[
|
||||
group && t("pool.groups.inGroup", { group }),
|
||||
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @file MemberPicker.tsx
|
||||
* @description The pool dialogs' member list (an account's Edit, a group's
|
||||
* Edit): filter chips by role (Admin / Developer / Analyst, each with its
|
||||
* count) plus "No group" for the users in no pool group, a search box, a
|
||||
* tick-all for what is shown, and one checkbox per user with their role and
|
||||
* where they are now. Pure UI: the caller owns the ticked set.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
|
||||
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
||||
/** A role, every user, or the users in no pool group. */
|
||||
export type MemberFilter = "all" | "nogroup" | AuthUser["role"];
|
||||
|
||||
/** The chips' filter applied to one user. */
|
||||
export function matchesFilter(
|
||||
u: Pick<ServerUserUsage, "id" | "role">,
|
||||
filter: MemberFilter,
|
||||
groupOf: (userId: number) => string | null
|
||||
): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "nogroup") return groupOf(u.id) === null;
|
||||
return u.role === filter;
|
||||
}
|
||||
|
||||
/** A row of filter chips with counts; `nogroup` only when groups exist. */
|
||||
export function MemberFilterChips({
|
||||
users,
|
||||
filter,
|
||||
onFilter,
|
||||
groupOf,
|
||||
grouped,
|
||||
}: {
|
||||
users: Pick<ServerUserUsage, "id" | "role">[];
|
||||
filter: MemberFilter;
|
||||
onFilter: (f: MemberFilter) => void;
|
||||
groupOf: (userId: number) => string | null;
|
||||
grouped: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const chips: { id: MemberFilter; label: string }[] = [
|
||||
{ id: "all", label: t("pool.members.all") },
|
||||
...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })),
|
||||
...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []),
|
||||
];
|
||||
return (
|
||||
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
|
||||
{chips.map((c) => {
|
||||
const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length;
|
||||
if (n === 0 && c.id !== "all" && c.id !== filter) return null;
|
||||
const on = filter === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onFilter(c.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
|
||||
on
|
||||
? "border-accent/50 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
<span className="tabular-nums opacity-70">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberPicker({
|
||||
users,
|
||||
chosen,
|
||||
onChange,
|
||||
groupOf,
|
||||
grouped,
|
||||
note,
|
||||
}: {
|
||||
users: ServerUserUsage[] | null;
|
||||
chosen: Set<number>;
|
||||
onChange: (next: Set<number>) => void;
|
||||
/** The pool group a user is in now, by name, or null. */
|
||||
groupOf: (userId: number) => string | null;
|
||||
/** Whether any pool group exists (shows the "No group" chip). */
|
||||
grouped: boolean;
|
||||
/** Extra text after a user's name (where they run now). */
|
||||
note?: (u: ServerUserUsage) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [filter, setFilter] = useState<MemberFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (users ?? []).filter(
|
||||
(u) =>
|
||||
matchesFilter(u, filter, groupOf) &&
|
||||
(!q ||
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.displayName ?? "").toLowerCase().includes(q))
|
||||
);
|
||||
const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id));
|
||||
const setMany = (ids: number[], on: boolean) => {
|
||||
const next = new Set(chosen);
|
||||
for (const id of ids) {
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="member-picker">
|
||||
{users && users.length > 0 && (
|
||||
<>
|
||||
<MemberFilterChips
|
||||
users={users}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
groupOf={groupOf}
|
||||
grouped={grouped}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
className="input w-full py-1 pl-7 text-xs"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("pool.members.search")}
|
||||
aria-label={t("pool.members.search")}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={allShown}
|
||||
disabled={shown.length === 0}
|
||||
onChange={(on) =>
|
||||
setMany(
|
||||
shown.map((u) => u.id),
|
||||
on
|
||||
)
|
||||
}
|
||||
label={t("pool.members.tickShown", { count: shown.length })}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users && users.length > 0 && shown.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
|
||||
)}
|
||||
{shown.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => setMany([u.id], on)}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<UserAvatar
|
||||
name={u.displayName || u.username}
|
||||
userId={u.id}
|
||||
avatarAt={u.avatarAt}
|
||||
letters={1}
|
||||
className="h-5 w-5 text-[10px]"
|
||||
/>
|
||||
{u.username}
|
||||
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||
{note?.(u)}
|
||||
</span>
|
||||
}
|
||||
className="min-w-0 flex-1"
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file MemberTmux.tsx
|
||||
* @description Each member's own tmux server as part of the Administration
|
||||
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
|
||||
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
|
||||
* older than the installed one, its sessions, whether an agent of theirs is
|
||||
* working — and `MemberTmuxBar` above the table, so an admin sees who still
|
||||
* runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`tmux.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||
|
||||
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
|
||||
export function useMemberTmux(enabled = true) {
|
||||
const [data, setData] = useState<MemberTmux | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.tmux()
|
||||
.then(setData)
|
||||
.catch(() => setData(null)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (enabled) void reload();
|
||||
}, [enabled, reload]);
|
||||
return { data, reload };
|
||||
}
|
||||
|
||||
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
|
||||
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
|
||||
return (
|
||||
<div className="text-sm text-fg-secondary">
|
||||
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
|
||||
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||
<div
|
||||
className="text-[11px] text-fg-muted"
|
||||
title={
|
||||
row.startedAt
|
||||
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
|
||||
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Above the users table: the installed tmux, the not-yet-installed tmux-member
|
||||
* warning, and (an admin) "Restart N idle members". Nothing without S3.
|
||||
*/
|
||||
export function MemberTmuxBar({
|
||||
data,
|
||||
isAdmin,
|
||||
onRestart,
|
||||
}: {
|
||||
data: MemberTmux | null;
|
||||
isAdmin: boolean;
|
||||
onRestart: (target: "idle") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!data?.available) return null;
|
||||
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
|
||||
<span className="inline-flex items-center gap-1.5 text-fg-muted">
|
||||
<TerminalSquare className="w-3.5 h-3.5" />
|
||||
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||
</span>
|
||||
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
|
||||
{isAdmin && idle.length > 0 && (
|
||||
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An admin's restart of one member's tmux, or of every idle outdated one. */
|
||||
export function TmuxRestartDialog({
|
||||
target,
|
||||
data,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
data: MemberTmux | null;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
|
||||
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
|
||||
}
|
||||
|
||||
function RestartDialog({
|
||||
target,
|
||||
idle,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
idle: MemberTmuxRow[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||
message={
|
||||
one
|
||||
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||
name: one.username,
|
||||
count: one.sessions ?? 0,
|
||||
})
|
||||
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||
}
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.tsx
|
||||
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
|
||||
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
||||
* beside the installed one, its sessions, whether an agent of theirs is
|
||||
* working — so an admin sees who still runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`tmux.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||
|
||||
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [data, setData] = useState<MemberTmux | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A member row = restart that one; "idle" = every idle outdated member.
|
||||
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.tmux()
|
||||
.then((r) => {
|
||||
setData(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!data?.available) return null;
|
||||
const rows = data.users.filter((u) => u.running);
|
||||
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="member-tmux">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<TerminalSquare className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
title={t("tmux.refresh")}
|
||||
aria-label={t("tmux.refresh")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && idleOutdated.length > 0 && (
|
||||
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
|
||||
<RotateCcw className="w-4 h-4" />{" "}
|
||||
{t("tmux.restartIdle", { count: idleOutdated.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!data.matched && (
|
||||
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
|
||||
{t("tmux.notMatched")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("tmux.user")}</th>
|
||||
<th className={th}>{t("tmux.version")}</th>
|
||||
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
|
||||
<th className={th}>{t("tmux.agents")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
|
||||
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||
{u.startedAt && (
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("tmux.since", {
|
||||
at: formatDateTime(new Date(u.startedAt).toISOString()),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions ?? "—"}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestarting(u)}
|
||||
title={t("tmux.restart", { name: u.username })}
|
||||
aria-label={t("tmux.restart", { name: u.username })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{restarting && (
|
||||
<RestartDialog
|
||||
target={restarting}
|
||||
idle={idleOutdated}
|
||||
onClose={() => setRestarting(null)}
|
||||
onDone={(msg) => {
|
||||
toast.success(msg);
|
||||
setRestarting(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartDialog({
|
||||
target,
|
||||
idle,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
idle: MemberTmuxRow[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||
message={
|
||||
one
|
||||
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||
name: one.username,
|
||||
count: one.sessions ?? 0,
|
||||
})
|
||||
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||
}
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user