Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1294ce1faa | |||
| 5d62003fe5 | |||
| 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 |
+5
-1
@@ -388,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 |
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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("")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { onLinkClick } from "../../lib/openInPreview";
|
||||
|
||||
type Block =
|
||||
| { kind: "code"; lang: string; code: string }
|
||||
@@ -305,7 +306,13 @@ const LABEL_CLASS = (word: string) =>
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, url)}
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
@@ -466,6 +473,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, href)}
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,7 +15,10 @@
|
||||
* 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 — 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 /
|
||||
@@ -99,6 +102,9 @@ 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";
|
||||
|
||||
@@ -229,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 (;;) {
|
||||
@@ -325,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;
|
||||
@@ -367,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
|
||||
@@ -437,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)
|
||||
@@ -453,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 ?? [];
|
||||
@@ -465,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);
|
||||
};
|
||||
@@ -614,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) {
|
||||
@@ -625,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;
|
||||
@@ -648,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;
|
||||
@@ -668,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)
|
||||
);
|
||||
@@ -681,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;
|
||||
@@ -692,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;
|
||||
@@ -716,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();
|
||||
}
|
||||
};
|
||||
@@ -924,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"
|
||||
@@ -951,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) ||
|
||||
@@ -986,18 +1046,7 @@ export function FileBrowser({
|
||||
/>
|
||||
)}
|
||||
{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();
|
||||
}}
|
||||
/>
|
||||
<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">
|
||||
@@ -1645,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 && (
|
||||
@@ -1679,9 +1709,7 @@ export function FileBrowser({
|
||||
</MenuItem>
|
||||
)}
|
||||
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>
|
||||
{t("fx.download")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{!ro && (
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
* (`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`
|
||||
@@ -54,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`).
|
||||
*
|
||||
@@ -92,6 +101,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -142,9 +152,14 @@ 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,
|
||||
@@ -164,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. */
|
||||
@@ -470,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>
|
||||
) : (
|
||||
@@ -771,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,
|
||||
@@ -861,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) => {
|
||||
@@ -876,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).
|
||||
@@ -909,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[]>>({});
|
||||
@@ -979,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
|
||||
@@ -1076,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;
|
||||
@@ -1266,7 +1225,6 @@ export function LaneChangesPanel({
|
||||
setTreeBusy(false);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setCommits(null);
|
||||
setData(null);
|
||||
setDraft(null);
|
||||
setCollapsed(new Set());
|
||||
@@ -1347,7 +1305,6 @@ export function LaneChangesPanel({
|
||||
if (!ok) return;
|
||||
setHistoryFile(path);
|
||||
setHistoryRef(null);
|
||||
setCommits(null);
|
||||
setMode("history");
|
||||
});
|
||||
};
|
||||
@@ -1476,13 +1433,21 @@ export function LaneChangesPanel({
|
||||
// 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.discardTickedTitle"),
|
||||
message: t("changes.discardTickedConfirm", {
|
||||
count: tickedPaths.length,
|
||||
untracked: files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardTicked"),
|
||||
message: t(
|
||||
untracked === 0
|
||||
? "changes.discardTickedConfirmTracked"
|
||||
: tracked === 0
|
||||
? "changes.discardTickedConfirmNew"
|
||||
: "changes.discardTickedConfirm",
|
||||
{ count: tracked, untracked }
|
||||
),
|
||||
confirmLabel: t("changes.discardTicked", { count: tickedPaths.length }),
|
||||
});
|
||||
if (!ok) return;
|
||||
const lane = laneId;
|
||||
@@ -1506,6 +1471,36 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
@@ -1593,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");
|
||||
@@ -1769,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2012,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
|
||||
@@ -2411,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 ${
|
||||
@@ -2429,6 +2368,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!notRepo && (
|
||||
<GitToolsButton
|
||||
onPick={setGitTool}
|
||||
hasRemote={git?.available ? git.hasRemote : undefined}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runReread}
|
||||
@@ -2637,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"
|
||||
@@ -2655,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}
|
||||
@@ -2671,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
|
||||
@@ -2683,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 ? (
|
||||
@@ -2885,7 +2790,54 @@ export function LaneChangesPanel({
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{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
|
||||
@@ -3006,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 ? (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -15,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
|
||||
@@ -23,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>
|
||||
*/
|
||||
|
||||
@@ -45,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";
|
||||
@@ -128,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
|
||||
@@ -150,6 +165,7 @@ export function LaneGitBar({
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
setChoice(null);
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
@@ -168,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
|
||||
@@ -190,20 +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("");
|
||||
@@ -221,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 () => {
|
||||
@@ -306,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])
|
||||
);
|
||||
@@ -424,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>
|
||||
@@ -473,9 +558,11 @@ export function LaneGitBar({
|
||||
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")}
|
||||
@@ -521,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;
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file UrlPreview.tsx
|
||||
* @description The session panel's Preview tab: a web page (a lane's dev
|
||||
* server, mostly) shown beside the console. One bar — address, Reload, Open
|
||||
* in a browser tab — and, before anything is open, the workspace's own ports
|
||||
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
|
||||
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
|
||||
* http(s), and the sandbox leaves out top navigation so a page cannot move
|
||||
* the dashboard away. A site that forbids framing (most login pages) shows
|
||||
* blank; that is the browser's rule, so the way out is the Open button. The
|
||||
* last address is remembered per workspace in localStorage (a viewer
|
||||
* convenience, never the server's).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Globe, RotateCw } from "lucide-react";
|
||||
import { normalizePreviewUrl } from "../../lib/openInPreview";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
|
||||
|
||||
function readSaved(laneId: number): string {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY(laneId)) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** The frame's sandbox: everything a web app needs, minus moving the
|
||||
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
|
||||
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
|
||||
const FRAME_SANDBOX =
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
|
||||
|
||||
export function UrlPreview({
|
||||
laneId,
|
||||
ports,
|
||||
request,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** The lane's bound ports, by name — the chips of the empty state. */
|
||||
ports: Record<string, number>;
|
||||
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
|
||||
request: { url: string; nonce: number } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [url, setUrl] = useState(() => readSaved(laneId));
|
||||
const [draft, setDraft] = useState(url);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
// Another workspace: its own last page.
|
||||
useEffect(() => {
|
||||
const saved = readSaved(laneId);
|
||||
setUrl(saved);
|
||||
setDraft(saved);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) go(request.url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
|
||||
}, [request?.nonce]);
|
||||
|
||||
function go(raw: string) {
|
||||
const next = normalizePreviewUrl(raw);
|
||||
if (!next) {
|
||||
toast.error(t("preview.badUrl"));
|
||||
return;
|
||||
}
|
||||
setUrl(next);
|
||||
setDraft(next);
|
||||
setReload((n) => n + 1);
|
||||
try {
|
||||
window.localStorage.setItem(KEY(laneId), next);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}
|
||||
|
||||
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
|
||||
const btn =
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
go(draft);
|
||||
}
|
||||
}}
|
||||
placeholder="localhost:3000"
|
||||
aria-label={t("preview.address")}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={() => setReload((n) => n + 1)}
|
||||
title={t("preview.reload")}
|
||||
aria-label={t("preview.reload")}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={() => url && window.open(url, "_blank", "noopener,noreferrer")}
|
||||
title={t("preview.openTab")}
|
||||
aria-label={t("preview.openTab")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{url ? (
|
||||
<iframe
|
||||
key={`${url}#${reload}`}
|
||||
src={url}
|
||||
title={t("preview.title")}
|
||||
sandbox={FRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
data-testid="url-preview-frame"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Globe className="h-6 w-6 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
|
||||
{portList.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{portList.map(([name, port]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => go(`localhost:${port}`)}
|
||||
title={name}
|
||||
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
localhost:{port}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
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";
|
||||
@@ -238,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.
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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,22 +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); opens an HTML file as the page
|
||||
* instead of downloading it.
|
||||
* 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) }));
|
||||
@@ -31,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: {
|
||||
@@ -74,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" },
|
||||
@@ -229,7 +254,8 @@ describe("FileExplorer", () => {
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -250,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"]);
|
||||
@@ -269,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");
|
||||
@@ -441,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();
|
||||
@@ -568,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"));
|
||||
@@ -587,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();
|
||||
@@ -614,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 () => {
|
||||
@@ -658,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"));
|
||||
@@ -677,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());
|
||||
@@ -445,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} />);
|
||||
@@ -844,9 +974,18 @@ describe("LaneChangesPanel", () => {
|
||||
// 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(() =>
|
||||
@@ -953,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"
|
||||
@@ -1083,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, {
|
||||
@@ -1141,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(() =>
|
||||
@@ -1179,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 () => {
|
||||
@@ -1198,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();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
* @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), that branches only the remote has are offered (and the list
|
||||
* 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>
|
||||
@@ -202,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 () => {
|
||||
|
||||
@@ -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,47 @@
|
||||
/**
|
||||
* @file UrlPreview.test.tsx
|
||||
* @description The Preview tab: port chips before a page is open, the
|
||||
* address bar opening a page in a sandboxed frame without top navigation,
|
||||
* a link's request opening its page, and the dashboard's own origin refused.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { UrlPreview } from "../UrlPreview";
|
||||
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
describe("UrlPreview", () => {
|
||||
it("offers the workspace's ports, and a chip opens that page", () => {
|
||||
render(<UrlPreview laneId={1} ports={{ web: 3001 }} request={null} />);
|
||||
fireEvent.click(screen.getByText("localhost:3001"));
|
||||
const frame = screen.getByTestId("url-preview-frame");
|
||||
expect(frame.getAttribute("src")).toBe("http://localhost:3001/");
|
||||
expect(frame.getAttribute("sandbox")).not.toMatch(/top-navigation/);
|
||||
expect(window.localStorage.getItem("agenthub.preview.url.1")).toBe("http://localhost:3001/");
|
||||
});
|
||||
|
||||
it("opens what the address bar names, and refuses the dashboard itself", () => {
|
||||
render(<UrlPreview laneId={2} ports={{}} request={null} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: window.location.origin + "/settings" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.queryByTestId("url-preview-frame")).toBeNull();
|
||||
fireEvent.change(input, { target: { value: "127.0.0.1:8080/app" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://127.0.0.1:8080/app"
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a link's request", () => {
|
||||
render(
|
||||
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />
|
||||
);
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://localhost:5173/"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -689,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,
|
||||
});
|
||||
|
||||
@@ -855,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.
|
||||
|
||||
@@ -90,6 +90,7 @@ import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nat
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
import { openInPreview } from "../../lib/openInPreview";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||
|
||||
@@ -462,7 +463,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = search;
|
||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((_e, uri) => {
|
||||
new WebLinksAddon((e, uri) => {
|
||||
// A local page opens in the session panel's Preview (one rule
|
||||
// for every link, `lib/openInPreview.ts`); Ctrl/Cmd keeps a tab.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && openInPreview(uri)) return;
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
@@ -166,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
|
||||
@@ -198,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>
|
||||
@@ -413,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(
|
||||
@@ -689,14 +655,66 @@ 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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -139,27 +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(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 () => {
|
||||
@@ -247,18 +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(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();
|
||||
@@ -298,8 +333,6 @@ describe("GitIdentitySettings", () => {
|
||||
alias: "git-2",
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("ssh://git-2/ → git@github.com")
|
||||
).toBeInTheDocument();
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,9 @@ export const i18nReady = i18n
|
||||
"admin",
|
||||
"onboarding",
|
||||
"artifacts",
|
||||
"gitRefs",
|
||||
"gitTools",
|
||||
"gitHistory",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
"installsYou": "You",
|
||||
"installsUnknown": "A removed user",
|
||||
"installsReplaced": "replaced an older copy",
|
||||
"versionAt": "Updated {{ago}}",
|
||||
"versionAtFull": "Last updated {{date}}",
|
||||
"installState.latest": "Latest",
|
||||
"installState.outdated": "Old version",
|
||||
"installState.removed": "Removed",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||
@@ -185,7 +190,7 @@
|
||||
"stat.installs": "Installs",
|
||||
"stat.files": "Files",
|
||||
"stat.size": "Size",
|
||||
"stat.updated": "Updated",
|
||||
"stat.updated": "Last updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"dismiss": "Dismiss",
|
||||
"compare": "Compare",
|
||||
"commit": "Commit",
|
||||
"blame": "Blame",
|
||||
"binary": "binary",
|
||||
"running": "Working…",
|
||||
"op.cherry-pick": "cherry-pick",
|
||||
"op.revert": "revert",
|
||||
"badge.local": "Branch {{name}}",
|
||||
"badge.remote": "Remote branch {{name}}",
|
||||
"badge.tag": "Tag {{name}}",
|
||||
"badge.head": "HEAD (detached)",
|
||||
"badge.current": "{{name}} — the branch you are on",
|
||||
"searchPlaceholder": "Search message or SHA — author:name path:file",
|
||||
"searchLabel": "Search the history",
|
||||
"clearSearch": "Clear the search",
|
||||
"allBranches": "All branches",
|
||||
"noMatches": "No commit matches this search",
|
||||
"selectCommit": "Select {{short}}",
|
||||
"selectedN_one": "{{count}} commit selected",
|
||||
"selectedN_other": "{{count}} commits selected",
|
||||
"clearSelection": "Clear",
|
||||
"mixedSelection": "Some of these are on this branch and some are not — select only one kind",
|
||||
"mergeCommitRefused": "A merge commit can't be cherry-picked or reverted here — do it in a terminal",
|
||||
"pickN_one": "Cherry-pick {{count}} commit onto {{branch}}",
|
||||
"pickN_other": "Cherry-pick {{count}} commits onto {{branch}}",
|
||||
"revertN_one": "Revert {{count}} commit",
|
||||
"revertN_other": "Revert {{count}} commits",
|
||||
"pickedN_one": "Cherry-picked {{count}} commit onto {{branch}}.",
|
||||
"pickedN_other": "Cherry-picked {{count}} commits onto {{branch}}.",
|
||||
"revertedN_one": "Reverted {{count}} commit on {{branch}}.",
|
||||
"revertedN_other": "Reverted {{count}} commits on {{branch}}.",
|
||||
"pausedConflicts_one": "The {{op}} stopped on {{count}} conflicted file — resolve it in Changes, then Continue",
|
||||
"pausedConflicts_other": "The {{op}} stopped on {{count}} conflicted files — resolve them in Changes, then Continue",
|
||||
"pausedEmpty": "The {{op}} stopped: a commit's changes are already on this branch — Skip it or Abort",
|
||||
"undo": "Undo",
|
||||
"undoHint": "Take these new commits back off the branch (only while they are not pushed)",
|
||||
"undoTitle": "Undo this {{op}}?",
|
||||
"undoBody_one": "The {{count}} new commit is removed from {{branch}}. Its changes stay staged, so nothing is lost. Refused if it was already pushed or the branch moved on.",
|
||||
"undoBody_other": "The {{count}} new commits are removed from {{branch}}. Their changes stay staged, so nothing is lost. Refused if they were already pushed or the branch moved on.",
|
||||
"undone_one": "Undone — {{count}} commit taken back, its changes are staged",
|
||||
"undone_other": "Undone — {{count}} commits taken back, their changes are staged",
|
||||
"showDetails": "Show details",
|
||||
"browseFiles": "Browse files at this commit",
|
||||
"compareWithCurrent": "Compare with current",
|
||||
"copyPatch": "Copy patch",
|
||||
"noPatch": "No patch for this commit",
|
||||
"patchOmitted_one": "{{count}} file holding secrets was left out of the patch",
|
||||
"patchOmitted_other": "{{count}} files holding secrets were left out of the patch",
|
||||
"createBranchHere": "Create branch here…",
|
||||
"detailsTitle": "Commit details",
|
||||
"noDetails": "Could not read this commit",
|
||||
"author": "Author",
|
||||
"committer": "Committer",
|
||||
"parents_one": "Parent",
|
||||
"parents_other": "Parents",
|
||||
"rootCommit": "none (first commit)",
|
||||
"refs": "Refs",
|
||||
"containedIn": "In",
|
||||
"viewAllChanges": "View all changes",
|
||||
"filesChanged_one": "{{count}} file changed",
|
||||
"filesChanged_other": "{{count}} files changed",
|
||||
"planning": "Working out the order…",
|
||||
"planUnavailable": "This folder is not a git repository",
|
||||
"pickTitle_one": "Cherry-pick {{count}} commit onto {{branch}}?",
|
||||
"pickTitle_other": "Cherry-pick {{count}} commits onto {{branch}}?",
|
||||
"revertTitle_one": "Revert {{count}} commit?",
|
||||
"revertTitle_other": "Revert {{count}} commits?",
|
||||
"pickIntro_one": "This commit is applied to {{branch}} as a new commit:",
|
||||
"pickIntro_other": "These commits are applied to {{branch}} one by one, in this order, each as a new commit:",
|
||||
"revertIntro_one": "A new commit that undoes this one is added to {{branch}}:",
|
||||
"revertIntro_other": "A new commit undoing each of these is added to {{branch}}, newest first, in this order:",
|
||||
"treeClean": "The working tree is clean.",
|
||||
"blockDirty": "The working tree has uncommitted changes. Commit or discard them first, so an Abort can never take them with it.",
|
||||
"blockSubdir": "This workspace is a subfolder of its repository; a cherry-pick or revert changes the whole repository, so run it from a workspace at the repo root.",
|
||||
"blockBusy": "A merge, cherry-pick, revert or rebase is already in progress. Finish or abort it first.",
|
||||
"pickResult": "Files in this workspace change right away. The original commits are not touched.",
|
||||
"revertResult": "History is not rewritten: the reverted commits stay, and new commits undo them.",
|
||||
"onConflict": "If a commit conflicts, the {{op}} pauses there: resolve the files in Changes, then Continue — or Skip that commit, or Abort to go back to exactly where you are now.",
|
||||
"pausedAt": "{{op}} paused at {{short}} \"{{subject}}\" ({{step}} of {{total}}):",
|
||||
"pausedBetween": "{{op}} paused ({{step}} of {{total}}):",
|
||||
"pausedHelpConflicts_one": "resolve the conflicted file, then Continue — or Skip this commit, or Abort to go back to where you were.",
|
||||
"pausedHelpConflicts_other": "resolve the {{count}} conflicted files, then Continue — or Skip this commit, or Abort to go back to where you were.",
|
||||
"pausedHelpReady": "every conflict is resolved — Continue to commit it and go on, or Skip this commit, or Abort to go back to where you were.",
|
||||
"pausedHelpResume": "Continue with the remaining commits, or Abort to go back to where you were.",
|
||||
"stillToApply_one": "Still to apply: {{list}}",
|
||||
"stillToApply_other": "Still to apply ({{count}}): {{list}}",
|
||||
"continue": "Continue",
|
||||
"continueBlocked": "Resolve every conflicted file first",
|
||||
"skip": "Skip this commit",
|
||||
"skipHint": "Drop this commit (and any resolution made for it) and go on with the next",
|
||||
"skipConfirm": "Click again to skip",
|
||||
"abort": "Abort",
|
||||
"abortHint": "Stop and put the branch back exactly where it was before",
|
||||
"abortConfirm": "Click again to abort the {{op}}",
|
||||
"aborted": "{{op}} aborted — the branch is back where it was",
|
||||
"sequenceDone_one": "{{op}} finished",
|
||||
"sequenceDone_other": "{{op}} finished",
|
||||
"status.A": "Added",
|
||||
"status.M": "Modified",
|
||||
"status.D": "Deleted",
|
||||
"status.R": "Renamed",
|
||||
"status.C": "Copied",
|
||||
"renamedFrom": "renamed from {{from}}",
|
||||
"noBlame": "No history to blame for this file",
|
||||
"blameTitle": "Blame {{path}}",
|
||||
"workingTree": "working tree",
|
||||
"blameBefore": "Blame the version before this change",
|
||||
"notCommitted": "not committed yet",
|
||||
"browseTitle": "Files at {{short}}",
|
||||
"pickAFile": "Pick a file on the left",
|
||||
"binaryFile": "Binary file — not shown",
|
||||
"compareTitle": "Compare",
|
||||
"compareUnavailable": "This folder is not a git repository",
|
||||
"compareA": "From (branch, tag or commit)",
|
||||
"compareB": "To (branch, tag or commit)",
|
||||
"swap": "Swap the two sides",
|
||||
"modeThree": "Changes since they diverged (A...B)",
|
||||
"modeTwo": "Difference between the two (A B)",
|
||||
"tabFiles_one": "{{count}} file",
|
||||
"tabFiles_other": "{{count}} files",
|
||||
"tabOnly_one": "Only in {{name}} ({{count}})",
|
||||
"tabOnly_other": "Only in {{name}} ({{count}})",
|
||||
"noDifference": "No file differs",
|
||||
"noCommitsSide": "No commit only on this side",
|
||||
"backToFiles": "Back to the files",
|
||||
"secretFile": "This file holds secrets and is not shown",
|
||||
"noTextChange": "No text change to show (binary, or a mode change)",
|
||||
"createBranchTitle": "New branch at {{short}}",
|
||||
"createBranch": "Create branch",
|
||||
"branchName": "Branch name",
|
||||
"createBranchHint": "The branch is created at this commit; you stay on the branch you are on.",
|
||||
"branchCreated": "Created branch {{branch}} at {{short}}",
|
||||
"createTagHere": "Create tag here…",
|
||||
"createTagTitle": "New tag at {{short}}",
|
||||
"createTag": "Create tag",
|
||||
"tagName": "Tag name",
|
||||
"tagMessage": "Message (optional — makes an annotated tag)",
|
||||
"createTagHint": "The tag stays on this machine until you push it from the Branches panel.",
|
||||
"tagCreated": "Created tag {{name}} at {{short}}"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"openPanel": "Manage branches & tags",
|
||||
"openPanelHint": "Manage branches & tags: rename, new branch from any commit or tag, upstream, merge, delete, tags",
|
||||
"title": "Branches and tags",
|
||||
"branches": "Branches",
|
||||
"tags": "Tags",
|
||||
"close": "Close",
|
||||
"searchBranches": "Search branches",
|
||||
"searchTags": "Search tags",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This folder is not a git repository.",
|
||||
"local": "Local",
|
||||
"remote": "Remote · {{remote}}",
|
||||
"noMatch": "No branch matches.",
|
||||
"truncated": "Only the first 1000 of each are shown.",
|
||||
"merging": "A merge, cherry-pick or revert is in progress: finish or abort it before changing branches or tags.",
|
||||
"current": "current",
|
||||
"protected": "protected",
|
||||
"worktree": "worktree",
|
||||
"gone": "upstream gone",
|
||||
"ahead": "ahead",
|
||||
"behind": "behind",
|
||||
"inSync": "in sync",
|
||||
"newFrom": "New branch from here",
|
||||
"newBranchName": "New branch name",
|
||||
"switchToIt": "Switch to it",
|
||||
"switchDirty": "Commit or discard your changes to switch.",
|
||||
"create": "Create",
|
||||
"rename": "Rename",
|
||||
"renameHint": "Rename this branch (its upstream setting moves with it)",
|
||||
"renameTo": "New name",
|
||||
"setUpstream": "Set upstream",
|
||||
"upstreamHint": "Track a remote branch: ahead/behind, pull and push follow it",
|
||||
"pickUpstream": "Pick a remote branch",
|
||||
"noRemoteBranches": "No remote branches fetched yet",
|
||||
"unsetUpstream": "Unset upstream",
|
||||
"apply": "Apply",
|
||||
"merge": "Merge",
|
||||
"mergeInto": "Merge into {{current}}",
|
||||
"mergeHint": "Merge this branch into {{current}} (a clean tree is needed)",
|
||||
"mergeConfirm": "Merge {{branch}} into {{current}}? If it conflicts, you resolve it in the Changes panel or abort it.",
|
||||
"delete": "Delete",
|
||||
"deleteHint": "Delete this local branch (git branch -d)",
|
||||
"deleteConfirm": "Delete the local branch {{branch}}? It is fully merged, so no commit is lost.",
|
||||
"deleteUnmerged": "Delete the local branch {{branch}}? It is not merged into the current branch, so git will refuse unless its upstream has every commit.",
|
||||
"why.current": "You are on this branch, so it cannot be deleted.",
|
||||
"why.protected": "Protected branch: it cannot be renamed or deleted here.",
|
||||
"why.worktree": "Checked out in another worktree: {{path}}",
|
||||
"why.unmerged": "Not merged into the current branch: git refuses to delete it unless its upstream has every commit.",
|
||||
"errLogin": "The remote wants a login. Use Fetch or Push on the git bar once to sign in (tick Remember), then try again.",
|
||||
"errSubdir": "Branches and tags belong to the whole repository: do this from a workspace at the repository root.",
|
||||
"tagNew": "New tag",
|
||||
"tagName": "Tag name, e.g. v1.2.0",
|
||||
"tagFrom": "On (branch, tag or commit sha) — empty for HEAD",
|
||||
"tagMessage": "Message (optional — makes an annotated tag)",
|
||||
"tagCreateLight": "Create tag",
|
||||
"tagCreateAnnotated": "Create annotated tag",
|
||||
"cancel": "Cancel",
|
||||
"noTags": "No tags.",
|
||||
"annotated": "annotated",
|
||||
"tagPush": "Push to origin",
|
||||
"tagPushHint": "Push only this tag (never --tags, never forced)",
|
||||
"tagPushConfirm": "Push the tag {{tag}} to the remote?",
|
||||
"tagDelete": "Delete tag",
|
||||
"tagDeleteHint": "Delete this tag on this machine only",
|
||||
"tagDeleteConfirm": "Delete the local tag {{tag}}? This cannot be undone here; a copy already pushed stays on the remote.",
|
||||
"done.create": "Created branch {{branch}}",
|
||||
"done.rename": "Renamed to {{branch}}",
|
||||
"done.setUpstream": "Now tracking {{upstream}}",
|
||||
"done.unsetUpstream": "Upstream removed",
|
||||
"done.merge": "Merged {{branch}}",
|
||||
"done.delete": "Deleted branch {{branch}}",
|
||||
"done.tagCreate": "Created tag {{tag}}",
|
||||
"done.tagPush": "Pushed tag {{tag}}",
|
||||
"done.tagDelete": "Deleted tag {{tag}}"
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading…",
|
||||
"pushed": "pushed",
|
||||
"forceAck": "I understand: the branch will need a force-push afterwards",
|
||||
"rewrites_one": "This rewrites {{count}} commit.",
|
||||
"rewrites_other": "This rewrites {{count}} commits.",
|
||||
"rewritesPushed_one": "This rewrites {{count}} commit; {{pushed}} already pushed, so a force-push will be needed.",
|
||||
"rewritesPushed_other": "This rewrites {{count}} commits; {{pushed}} are already pushed, so a force-push will be needed.",
|
||||
"doneTitle": "Done.",
|
||||
"needsForcePushAfter": "Commits that were already pushed were rewritten: push with Force push (with lease) when you are sure.",
|
||||
"undo": "Undo",
|
||||
"undoConfirm": "Click again to undo",
|
||||
"undoHint": "Puts the branch back at {{sha}} (local changes are kept).",
|
||||
"undone": "Undone: the branch is back where it was",
|
||||
"blocked.subdir": "This changes the whole branch, so it only runs from a workspace at the repository root.",
|
||||
"blocked.operation": "A {{op}} is in progress: finish or abort it first.",
|
||||
"blocked.dirty": "Commit or discard your uncommitted changes first.",
|
||||
|
||||
"menu.label": "History tools",
|
||||
"menu.rebase": "Rebase onto…",
|
||||
"menu.interactive": "Edit unpushed commits…",
|
||||
"menu.reflog": "Reflog",
|
||||
"menu.forcePush": "Force push (with lease)…",
|
||||
"menu.remotes": "Remotes…",
|
||||
"menu.resetHere": "Reset branch to here…",
|
||||
"menu.interactiveHere": "Edit commits after this one…",
|
||||
|
||||
"merge.open": "Merge editor",
|
||||
"merge.title": "Merge editor — {{path}}",
|
||||
"merge.incoming": "Incoming: {{ref}}",
|
||||
"merge.incomingRebase": "Incoming (commit being replayed): {{ref}}",
|
||||
"merge.incomingPlain": "Incoming",
|
||||
"merge.current": "Current: {{branch}}",
|
||||
"merge.currentRebase": "Current (the branch you are rebasing onto)",
|
||||
"merge.base": "Common base",
|
||||
"merge.result": "Result",
|
||||
"merge.sideGone": "Deleted on this side",
|
||||
"merge.blockOf": "Conflict {{n}} of {{total}}",
|
||||
"merge.acceptCurrent": "Accept current",
|
||||
"merge.acceptIncoming": "Accept incoming",
|
||||
"merge.bothCurrentFirst": "Both (current first)",
|
||||
"merge.bothIncomingFirst": "Both (incoming first)",
|
||||
"merge.editBlock": "Edit",
|
||||
"merge.editDone": "Done",
|
||||
"merge.undoChoice": "Undo",
|
||||
"merge.left_one": "{{count}} of {{total}} conflicts left",
|
||||
"merge.left_other": "{{count}} of {{total}} conflicts left",
|
||||
"merge.allChosen": "Every conflict has a choice",
|
||||
"merge.wholeMode": "Editing the whole result",
|
||||
"merge.prev": "Previous conflict",
|
||||
"merge.next": "Next conflict",
|
||||
"merge.showBase": "Show base",
|
||||
"merge.editWhole": "Edit whole result",
|
||||
"merge.backToBlocks": "Back to conflicts",
|
||||
"merge.save": "Save result",
|
||||
"merge.saved": "{{path}} resolved",
|
||||
"merge.markersLeft": "The result still has conflict markers (<<<<<<< ======= >>>>>>>): resolve every block first.",
|
||||
"merge.stale": "The file changed on disk since it was opened. Close and open it again.",
|
||||
"merge.binary": "This file is binary: take a whole side from the conflict view.",
|
||||
"merge.tooBig": "This file is too large for the merge editor: take a whole side or edit it in a terminal.",
|
||||
"merge.noWorking": "One side deleted this file: keep or delete it with Keep mine / Take theirs.",
|
||||
"merge.unbalanced": "The conflict markers in this file are unbalanced: edit it by hand.",
|
||||
|
||||
"reflog.title": "Reflog",
|
||||
"reflog.of": "Where the branch has been:",
|
||||
"reflog.hint": "Every place HEAD or the branch pointed at, newest first. A commit you lost to a reset or a rebase is still here: Reset here brings it back.",
|
||||
"reflog.empty": "Nothing recorded yet.",
|
||||
"reflog.resetHere": "Reset here…",
|
||||
"reflog.more": "Show more",
|
||||
|
||||
"rebase.title": "Rebase {{branch}} onto…",
|
||||
"rebase.onto": "Branch to rebase onto",
|
||||
"rebase.pick": "Pick a branch",
|
||||
"rebase.explain": "Your branch's own commits are replayed one by one on top of the branch you pick. If a commit conflicts, the rebase stops and the Changes panel offers Continue, Skip and Abort.",
|
||||
"rebase.nothing": "Nothing to replay: this branch has no commit that {{onto}} lacks.",
|
||||
"rebase.summary_one": "{{count}} commit will be replayed onto {{onto}} ({{behind}} new commit(s) there).",
|
||||
"rebase.summary_other": "{{count}} commits will be replayed onto {{onto}} ({{behind}} new commit(s) there).",
|
||||
"rebase.run": "Rebase",
|
||||
"rebase.done": "Rebased onto {{onto}}",
|
||||
"rebase.stopped_one": "The rebase stopped: {{count}} file is in conflict. Resolve it, then Continue.",
|
||||
"rebase.stopped_other": "The rebase stopped: {{count}} files are in conflict. Resolve them, then Continue.",
|
||||
|
||||
"rebaseBanner.skip": "Skip commit",
|
||||
"rebaseBanner.skipHint": "Drop the commit the rebase stopped on and go on with the next one",
|
||||
"rebaseBanner.finished": "Rebase finished",
|
||||
|
||||
"interactive.title": "Edit commits",
|
||||
"interactive.explain": "Reorder with the arrows, and choose per commit: keep it, reword its message, squash or fixup it into the commit above, or drop it. Nothing changes until you press Rewrite.",
|
||||
"interactive.merges": "This range contains a merge commit; rewrite it in a terminal instead.",
|
||||
"interactive.nothing": "No unpushed commits to edit.",
|
||||
"interactive.action": "What to do with {{short}}",
|
||||
"interactive.actions.pick": "Keep",
|
||||
"interactive.actions.reword": "Reword",
|
||||
"interactive.actions.squash": "Squash into above",
|
||||
"interactive.actions.fixup": "Fixup into above (drop message)",
|
||||
"interactive.actions.drop": "Drop",
|
||||
"interactive.up": "Move up",
|
||||
"interactive.down": "Move down",
|
||||
"interactive.message": "Commit message",
|
||||
"interactive.squashHint": "Leave empty to join both messages",
|
||||
"interactive.problem.first": "The first kept commit cannot be squashed: there is nothing above it.",
|
||||
"interactive.problem.allDropped": "Every commit is dropped: use Reset branch to here instead.",
|
||||
"interactive.problem.reword": "A reworded commit needs a message.",
|
||||
"interactive.run": "Rewrite",
|
||||
"interactive.runConfirm": "Click again to rewrite",
|
||||
"interactive.done": "Commits rewritten",
|
||||
|
||||
"reset.title": "Reset {{branch}} to {{short}}",
|
||||
"reset.summary": "{{dropped}} commit(s) leave {{branch}} and {{added}} join it.",
|
||||
"reset.already": "The branch is already at this commit.",
|
||||
"reset.unrelated": "This commit is not on this branch's line of history, so the branch cannot be reset to it here.",
|
||||
"reset.mode": "What happens to your files",
|
||||
"reset.modes.soft": "Soft",
|
||||
"reset.modes.mixed": "Mixed",
|
||||
"reset.modes.keep": "Keep",
|
||||
"reset.modes.hard": "Hard",
|
||||
"reset.modesHelp.soft": "Only the branch moves; the changes of the removed commits stay staged.",
|
||||
"reset.modesHelp.mixed": "The branch moves; those changes stay in your files, unstaged.",
|
||||
"reset.modesHelp.keep": "Files change to the commit, but git refuses if that would overwrite a change you have not committed.",
|
||||
"reset.modesHelp.hard": "Files change to the commit exactly. Only on a clean tree, so nothing uncommitted is lost.",
|
||||
"reset.hardDirty": "Hard reset runs only when there are no uncommitted changes. Use Keep, or commit/discard first.",
|
||||
"reset.typeBranch": "Type the branch name ({{branch}}) to confirm",
|
||||
"reset.pushed_one": "{{count}} of the commits leaving the branch is already pushed, so a force-push will be needed.",
|
||||
"reset.pushed_other": "{{count}} of the commits leaving the branch are already pushed, so a force-push will be needed.",
|
||||
"reset.run": "Reset ({{mode}})",
|
||||
"reset.done": "Branch reset to {{short}}",
|
||||
|
||||
"forcePush.title": "Force push (with lease)",
|
||||
"forcePush.where": "Pushes {{branch}} to {{remote}}/{{remoteBranch}}, replacing what is there.",
|
||||
"forcePush.replaces": "It replaces {{sha}} on the remote (your branch is {{ahead}} ahead, {{behind}} behind it).",
|
||||
"forcePush.newBranch": "The remote does not have this branch yet.",
|
||||
"forcePush.dropsRemote_one": "{{count}} commit on the remote is not in your branch and will be gone from it.",
|
||||
"forcePush.dropsRemote_other": "{{count}} commits on the remote are not in your branch and will be gone from it.",
|
||||
"forcePush.protected": "This is a protected branch (main, master, develop…): force-pushing it is turned off on this server.",
|
||||
"forcePush.lease": "With lease: the push is refused if someone pushed to the remote branch since your last fetch.",
|
||||
"forcePush.run": "Force push",
|
||||
"forcePush.runConfirm": "Click again to force push",
|
||||
"forcePush.done": "Force-pushed to {{remote}}/{{branch}}",
|
||||
"forcePush.stale": "The remote branch changed since your last fetch. Fetch, look at what is there, then try again.",
|
||||
|
||||
"remotes.title": "Remotes",
|
||||
"remotes.none": "This repository has no remote.",
|
||||
"remotes.default": "Default branch: {{branch}}",
|
||||
"remotes.rename": "Rename",
|
||||
"remotes.newName": "New name",
|
||||
"remotes.remove": "Remove",
|
||||
"remotes.removeConfirm": "Click again to remove",
|
||||
"remotes.branches_one": "{{count}} branch",
|
||||
"remotes.branches_other": "{{count}} branches",
|
||||
"remotes.deleteBranch": "Delete on remote",
|
||||
"remotes.deleteBranchConfirm": "Click again to delete",
|
||||
"remotes.branchLocked": "The default branch and main/master/develop cannot be deleted here",
|
||||
"remotes.add": "Add a remote",
|
||||
"remotes.name": "Name",
|
||||
"remotes.url": "URL",
|
||||
"remotes.urlHint": "https:// (without a login in it), ssh:// or user@host:path.",
|
||||
"remotes.addButton": "Add",
|
||||
"remotes.added": "Remote {{name}} added",
|
||||
"remotes.renamed": "Remote {{from}} renamed to {{to}}",
|
||||
"remotes.removed": "Remote {{name}} removed",
|
||||
"remotes.branchDeleted": "{{ref}} deleted on the remote"
|
||||
}
|
||||
@@ -197,7 +197,7 @@
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
"gitBar.switchCurrent": "current",
|
||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
||||
@@ -215,7 +215,6 @@
|
||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||
"clone.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.background": "Run in background",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
@@ -232,6 +231,41 @@
|
||||
"clone.phase.resolving": "Unpacking",
|
||||
"clone.phase.checkout": "Writing files",
|
||||
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
|
||||
"cloneJobs.backgroundHint": "The clone runs in the background: follow it in the corner of the screen, on any page — even after a reload.",
|
||||
"cloneJobs.starting": "Starting…",
|
||||
"cloneJobs.started": "Started — progress is in the corner of the screen.",
|
||||
"cloneJobs.cloning": "Cloning {{name}}",
|
||||
"cloneJobs.queued": "Waiting in the clone queue — #{{position}}",
|
||||
"cloneJobs.done": "Cloned {{name}}",
|
||||
"cloneJobs.failedTitle": "Could not clone {{name}}",
|
||||
"cloneJobs.cancelledTitle": "Stopped cloning {{name}}",
|
||||
"cloneJobs.failed": "The clone failed.",
|
||||
"cloneJobs.interrupted": "AgentHub restarted while this clone was running.",
|
||||
"cloneJobs.cancelled": "You stopped this clone.",
|
||||
"cloneJobs.cancelledToast": "Stopped cloning {{name}}.",
|
||||
"cloneJobs.failedToast": "Could not clone {{name}}: {{message}}",
|
||||
"cloneJobs.doneWorkspace": "Cloned {{name}} into {{path}} — opened as workspace “{{workspace}}”.",
|
||||
"cloneJobs.doneSource": "Cloned {{name}} into {{path}} and added it to the workspace.",
|
||||
"cloneJobs.openWorkspace": "Open workspace",
|
||||
"cloneJobs.open": "Open",
|
||||
"cloneJobs.retry": "Retry",
|
||||
"cloneJobs.retryLogin": "Retry with this login",
|
||||
"cloneJobs.stop": "Stop clone",
|
||||
"cloneJobs.stopConfirm": "Click again to stop",
|
||||
"cloneJobs.stopUnsupported": "This AgentHub server cannot stop a clone yet — update it, or wait for the clone to finish.",
|
||||
"cloneJobs.dismiss": "Dismiss",
|
||||
"jobs.dock": "Background tasks",
|
||||
"jobs.count_one": "{{count}} background task",
|
||||
"jobs.count_other": "{{count}} background tasks",
|
||||
"jobs.running_one": "{{count}} running",
|
||||
"jobs.running_other": "{{count}} running",
|
||||
"jobs.expand": "Show background tasks",
|
||||
"uploads.stop": "Stop upload",
|
||||
"uploads.stopConfirm": "Click again to stop",
|
||||
"uploads.stopping": "Stopping…",
|
||||
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
||||
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
||||
"uploads.interrupted": "The upload of {{total}} file(s) to {{folder}} was interrupted by a page reload — {{done}} sent.",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
@@ -343,6 +377,7 @@
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file",
|
||||
"row.actions": "Workspace actions",
|
||||
"row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions",
|
||||
"row.folderMissing": "Folder deleted — remove this workspace from its ⋯ menu",
|
||||
"menu.rename": "Rename",
|
||||
"menu.newHere": "New workspace here…",
|
||||
"menu.copyPath": "Copy path",
|
||||
@@ -350,6 +385,14 @@
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Preview",
|
||||
"preview.title": "Page preview",
|
||||
"preview.address": "Page address",
|
||||
"preview.reload": "Reload",
|
||||
"preview.openTab": "Open in a browser tab",
|
||||
"preview.badUrl": "Only an http(s) page other than the dashboard can be previewed",
|
||||
"preview.empty": "Type an address, or click a localhost link in the chat or terminal.",
|
||||
"preview.loginHint": "A blank page? The site may forbid embedding or need a login — open it in a browser tab.",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
"replay.hint": "Sending resumes the session in a free pane.",
|
||||
"replay.send": "Send",
|
||||
@@ -535,6 +578,8 @@
|
||||
"changes.discardTicked": "Discard ({{count}})",
|
||||
"changes.discardTickedTitle": "Discard the ticked changes?",
|
||||
"changes.discardTickedConfirm": "The ticked changes in {{count}} file(s) go back to the last commit, and {{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files and blocks are kept. This cannot be undone.",
|
||||
"changes.discardTickedConfirmTracked": "The ticked changes in {{count}} file(s) go back to the last commit. Unticked files and blocks are kept. This cannot be undone.",
|
||||
"changes.discardTickedConfirmNew": "{{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files are kept. This cannot be undone.",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
@@ -567,7 +612,6 @@
|
||||
"fx.newFile": "New file",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Upload files",
|
||||
"fx.uploadFolder": "Upload a folder",
|
||||
"fx.download": "Download",
|
||||
@@ -590,6 +634,7 @@
|
||||
"fx.dropHint": "Drag files or folders here to upload · drag onto a folder to move (Ctrl: copy)",
|
||||
"fx.uploading": "Uploading {{done}}/{{total}}…",
|
||||
"fx.uploaded": "Uploaded {{count}} file(s).",
|
||||
"fx.gitSkipped": "Skipped {{count}} file(s) inside .git — nothing is uploaded into .git.",
|
||||
"fx.uploadFailed": "Could not upload {{name}}: {{message}}",
|
||||
"fx.tooBig": "{{size}} is more than one upload may carry ({{max}}). Upload fewer files at a time.",
|
||||
"fx.trashed": "Moved {{count}} item(s) to the Trash.",
|
||||
@@ -675,5 +720,38 @@
|
||||
"menu.newGroupWith": "New group…",
|
||||
"tree.groupAddWorkspace": "New workspace in this group…",
|
||||
"tree.groupFoldAll": "Fold all groups",
|
||||
"tree.groupUnfoldAll": "Unfold all groups"
|
||||
"tree.groupUnfoldAll": "Unfold all groups",
|
||||
"gitBar.switchDirtyHint": "{{count}} uncommitted file(s): you will be asked whether to bring them along or shelve them",
|
||||
"gitBar.choiceTitle": "Switch to {{branch}}",
|
||||
"gitBar.choiceMessage": "You have {{count}} uncommitted file(s). Switch to {{branch}} and…",
|
||||
"gitBar.choiceCarry": "Bring my changes",
|
||||
"gitBar.choiceCarryHint": "Switch and keep the uncommitted changes in the working tree (git refuses if a file would be overwritten)",
|
||||
"gitBar.choiceShelve": "Shelve and switch",
|
||||
"gitBar.choiceShelveHint": "Set the changes aside on a shelf, then switch; restore them later from Shelves",
|
||||
"gitBar.carryConflict": "Your changes cannot come along to {{branch}}: git would overwrite these files. Shelve them instead?",
|
||||
"gitBar.branchCreateSwitch": "Create & switch",
|
||||
"gitBar.branchCreateSwitchHint": "Create this branch and switch to it; uncommitted changes come along",
|
||||
"gitBar.shelvedOn": "Your changes on {{branch}} are on a shelf",
|
||||
"gitBar.restoreOfferTitle": "Restore shelved changes?",
|
||||
"gitBar.restoreOffer": "{{branch}} has {{count}} shelf/shelves. Restore the newest ({{files}} file(s)) now?",
|
||||
"shelves.title": "Shelves ({{count}})",
|
||||
"shelves.files": "{{count}} file(s)",
|
||||
"shelves.restore": "Restore",
|
||||
"shelves.restoreHint": "Put these changes back (needs a clean working tree); the shelf is deleted once it applies cleanly",
|
||||
"shelves.delete": "Delete",
|
||||
"shelves.deleteConfirm": "Delete for good?",
|
||||
"shelves.deleteHint": "Delete this shelf and the changes in it (two clicks)",
|
||||
"shelves.deleted": "Shelf deleted",
|
||||
"shelves.restored": "Shelved changes restored",
|
||||
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
|
||||
"shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf",
|
||||
"shelves.shelveTicked": "Shelve ({{count}})",
|
||||
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
|
||||
"shelves.shelveApply": "Shelve {{count}} file(s)",
|
||||
"shelves.notePlaceholder": "Note for this shelf (optional)…",
|
||||
"shelves.cancel": "Cancel",
|
||||
"shelves.shelved": "{{count}} file(s) shelved",
|
||||
"shelves.agentTitle": "An agent is working here",
|
||||
"shelves.agentMessage": "An agent in this workspace is working right now. This moves files under it, and it may get confused or overwrite them. Continue anyway?",
|
||||
"shelves.agentConfirm": "Continue"
|
||||
}
|
||||
|
||||
@@ -911,11 +911,13 @@
|
||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||
"stepCopy": "Copy the public key.",
|
||||
"awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.",
|
||||
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:",
|
||||
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here — one key ID works in every region:",
|
||||
"awsKeyId": "SSH key ID",
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Save",
|
||||
"awsSaved": "SSH key ID saved for {{region}}",
|
||||
"awsSavedAll": "SSH key ID saved for every region",
|
||||
"awsSavedNamed": "SSH key ID saved for {{name}}",
|
||||
"awsAllRegions": "Every region",
|
||||
"awsUseAll": "Use in every region",
|
||||
"otherTitle": "Other SSH hosts (advanced)",
|
||||
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||
"otherHost": "Host",
|
||||
@@ -929,7 +931,7 @@
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabOther": "Other hosts",
|
||||
"addKey": "Add another key",
|
||||
|
||||
@@ -143,6 +143,11 @@
|
||||
"installsYou": "Bạn",
|
||||
"installsUnknown": "Người dùng đã xoá",
|
||||
"installsReplaced": "thay bản cũ",
|
||||
"versionAt": "Cập nhật {{ago}}",
|
||||
"versionAtFull": "Cập nhật lần cuối {{date}}",
|
||||
"installState.latest": "Bản mới nhất",
|
||||
"installState.outdated": "Bản cũ",
|
||||
"installState.removed": "Đã gỡ",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||
@@ -186,7 +191,7 @@
|
||||
"stat.installs": "Lượt cài",
|
||||
"stat.files": "File",
|
||||
"stat.size": "Dung lượng",
|
||||
"stat.updated": "Cập nhật",
|
||||
"stat.updated": "Cập nhật lần cuối",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"close": "Đóng",
|
||||
"cancel": "Hủy",
|
||||
"dismiss": "Ẩn",
|
||||
"compare": "So sánh",
|
||||
"commit": "Commit",
|
||||
"blame": "Blame",
|
||||
"binary": "nhị phân",
|
||||
"running": "Đang chạy…",
|
||||
"op.cherry-pick": "cherry-pick",
|
||||
"op.revert": "revert",
|
||||
"badge.local": "Nhánh {{name}}",
|
||||
"badge.remote": "Nhánh remote {{name}}",
|
||||
"badge.tag": "Tag {{name}}",
|
||||
"badge.head": "HEAD (không ở nhánh nào)",
|
||||
"badge.current": "{{name}} — nhánh bạn đang đứng",
|
||||
"searchPlaceholder": "Tìm theo nội dung hoặc SHA — author:tên path:file",
|
||||
"searchLabel": "Tìm trong lịch sử",
|
||||
"clearSearch": "Xóa ô tìm",
|
||||
"allBranches": "Mọi nhánh",
|
||||
"noMatches": "Không có commit nào khớp",
|
||||
"selectCommit": "Chọn {{short}}",
|
||||
"selectedN_other": "Đã chọn {{count}} commit",
|
||||
"clearSelection": "Bỏ chọn",
|
||||
"mixedSelection": "Có commit đã nằm trên nhánh này, có commit thì chưa — hãy chỉ chọn một loại",
|
||||
"mergeCommitRefused": "Không cherry-pick hay revert được merge commit ở đây — hãy làm trong terminal",
|
||||
"pickN_other": "Cherry-pick {{count}} commit vào {{branch}}",
|
||||
"revertN_other": "Revert {{count}} commit",
|
||||
"pickedN_other": "Đã cherry-pick {{count}} commit vào {{branch}}.",
|
||||
"revertedN_other": "Đã revert {{count}} commit trên {{branch}}.",
|
||||
"pausedConflicts_other": "{{op}} dừng lại vì {{count}} file bị conflict — hãy giải quyết trong Changes rồi bấm Tiếp tục",
|
||||
"pausedEmpty": "{{op}} dừng lại: thay đổi của một commit đã có sẵn trên nhánh này — hãy Bỏ qua hoặc Hủy",
|
||||
"undo": "Hoàn tác",
|
||||
"undoHint": "Gỡ các commit mới này khỏi nhánh (chỉ khi chúng chưa được push)",
|
||||
"undoTitle": "Hoàn tác lần {{op}} này?",
|
||||
"undoBody_other": "{{count}} commit mới sẽ bị gỡ khỏi {{branch}}. Thay đổi của chúng vẫn được giữ ở trạng thái staged nên không mất gì. Sẽ bị từ chối nếu đã push hoặc nhánh đã có commit mới hơn.",
|
||||
"undone_other": "Đã hoàn tác {{count}} commit, thay đổi vẫn đang ở trạng thái staged",
|
||||
"showDetails": "Xem chi tiết",
|
||||
"browseFiles": "Xem các file ở commit này",
|
||||
"compareWithCurrent": "So sánh với nhánh hiện tại",
|
||||
"copyPatch": "Sao chép patch",
|
||||
"noPatch": "Commit này không có patch",
|
||||
"patchOmitted_other": "Đã bỏ {{count}} file chứa thông tin bí mật ra khỏi patch",
|
||||
"createBranchHere": "Tạo nhánh tại đây…",
|
||||
"detailsTitle": "Chi tiết commit",
|
||||
"noDetails": "Không đọc được commit này",
|
||||
"author": "Tác giả",
|
||||
"committer": "Người commit",
|
||||
"parents_other": "Commit cha",
|
||||
"rootCommit": "không có (commit đầu tiên)",
|
||||
"refs": "Ref",
|
||||
"containedIn": "Có trong",
|
||||
"viewAllChanges": "Xem mọi thay đổi",
|
||||
"filesChanged_other": "{{count}} file thay đổi",
|
||||
"planning": "Đang xếp thứ tự…",
|
||||
"planUnavailable": "Thư mục này không phải git repo",
|
||||
"pickTitle_other": "Cherry-pick {{count}} commit vào {{branch}}?",
|
||||
"revertTitle_other": "Revert {{count}} commit?",
|
||||
"pickIntro_other": "Lần lượt áp từng commit vào {{branch}} theo đúng thứ tự dưới đây, mỗi commit thành một commit mới:",
|
||||
"revertIntro_other": "Thêm vào {{branch}} các commit mới để đảo ngược từng commit dưới đây, commit mới nhất trước:",
|
||||
"treeClean": "Thư mục làm việc đang sạch.",
|
||||
"blockDirty": "Thư mục làm việc còn thay đổi chưa commit. Hãy commit hoặc bỏ chúng trước, để lỡ có Hủy cũng không mất gì.",
|
||||
"blockSubdir": "Workspace này là thư mục con của repo; cherry-pick hay revert thay đổi cả repo, nên hãy chạy từ workspace ở thư mục gốc của repo.",
|
||||
"blockBusy": "Đang có một merge, cherry-pick, revert hoặc rebase dở dang. Hãy hoàn tất hoặc hủy nó trước.",
|
||||
"pickResult": "File trong workspace thay đổi ngay. Các commit gốc giữ nguyên.",
|
||||
"revertResult": "Lịch sử không bị viết lại: commit cũ vẫn còn, các commit mới sẽ đảo ngược chúng.",
|
||||
"onConflict": "Nếu một commit bị conflict, {{op}} sẽ dừng ở đó: giải quyết file trong Changes rồi bấm Tiếp tục — hoặc Bỏ qua commit đó, hoặc Hủy để quay về đúng trạng thái hiện tại.",
|
||||
"pausedAt": "{{op}} đang dừng ở {{short}} \"{{subject}}\" ({{step}}/{{total}}):",
|
||||
"pausedBetween": "{{op}} đang dừng ({{step}}/{{total}}):",
|
||||
"pausedHelpConflicts_other": "hãy giải quyết {{count}} file bị conflict rồi bấm Tiếp tục — hoặc Bỏ qua commit này, hoặc Hủy để quay về như trước.",
|
||||
"pausedHelpReady": "đã giải quyết hết conflict — bấm Tiếp tục để commit và làm tiếp, hoặc Bỏ qua commit này, hoặc Hủy để quay về như trước.",
|
||||
"pausedHelpResume": "bấm Tiếp tục để áp các commit còn lại, hoặc Hủy để quay về như trước.",
|
||||
"stillToApply_other": "Còn lại ({{count}}): {{list}}",
|
||||
"continue": "Tiếp tục",
|
||||
"continueBlocked": "Hãy giải quyết hết các file bị conflict trước",
|
||||
"skip": "Bỏ qua commit này",
|
||||
"skipHint": "Bỏ commit này (kể cả phần đã giải quyết cho nó) và làm tiếp commit sau",
|
||||
"skipConfirm": "Bấm lần nữa để bỏ qua",
|
||||
"abort": "Hủy",
|
||||
"abortHint": "Dừng lại và đưa nhánh về đúng như trước khi bắt đầu",
|
||||
"abortConfirm": "Bấm lần nữa để hủy {{op}}",
|
||||
"aborted": "Đã hủy {{op}} — nhánh đã về như trước",
|
||||
"sequenceDone_other": "{{op}} xong",
|
||||
"status.A": "Thêm mới",
|
||||
"status.M": "Sửa",
|
||||
"status.D": "Xóa",
|
||||
"status.R": "Đổi tên",
|
||||
"status.C": "Sao chép",
|
||||
"renamedFrom": "đổi tên từ {{from}}",
|
||||
"noBlame": "File này chưa có lịch sử để blame",
|
||||
"blameTitle": "Blame {{path}}",
|
||||
"workingTree": "bản đang làm",
|
||||
"blameBefore": "Blame phiên bản trước thay đổi này",
|
||||
"notCommitted": "chưa commit",
|
||||
"browseTitle": "Các file ở {{short}}",
|
||||
"pickAFile": "Chọn một file bên trái",
|
||||
"binaryFile": "File nhị phân — không hiển thị",
|
||||
"compareTitle": "So sánh",
|
||||
"compareA": "Từ (nhánh, tag hoặc commit)",
|
||||
"compareB": "Đến (nhánh, tag hoặc commit)",
|
||||
"compareUnavailable": "Thư mục này không phải git repo",
|
||||
"swap": "Đổi chỗ hai bên",
|
||||
"modeThree": "Thay đổi kể từ lúc tách nhánh (A...B)",
|
||||
"modeTwo": "Khác biệt giữa hai bên (A B)",
|
||||
"tabFiles_other": "{{count}} file",
|
||||
"tabOnly_other": "Chỉ có ở {{name}} ({{count}})",
|
||||
"noDifference": "Không có file nào khác nhau",
|
||||
"noCommitsSide": "Bên này không có commit riêng",
|
||||
"backToFiles": "Quay lại danh sách file",
|
||||
"secretFile": "File này chứa thông tin bí mật nên không hiển thị",
|
||||
"noTextChange": "Không có thay đổi dạng văn bản (file nhị phân hoặc chỉ đổi quyền)",
|
||||
"createBranchTitle": "Nhánh mới tại {{short}}",
|
||||
"createBranch": "Tạo nhánh",
|
||||
"branchName": "Tên nhánh",
|
||||
"createBranchHint": "Nhánh được tạo tại commit này; bạn vẫn ở nhánh hiện tại.",
|
||||
"branchCreated": "Đã tạo nhánh {{branch}} tại {{short}}",
|
||||
"createTagHere": "Tạo tag tại đây…",
|
||||
"createTagTitle": "Tag mới tại {{short}}",
|
||||
"createTag": "Tạo tag",
|
||||
"tagName": "Tên tag",
|
||||
"tagMessage": "Lời nhắn (không bắt buộc — có lời nhắn sẽ thành annotated tag)",
|
||||
"createTagHint": "Tag chỉ nằm trên máy này cho tới khi bạn push nó từ bảng Nhánh.",
|
||||
"tagCreated": "Đã tạo tag {{name}} tại {{short}}"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"openPanel": "Nhánh và tag",
|
||||
"openPanelHint": "Nhánh và tag: đổi tên, tạo nhánh từ commit hoặc tag bất kỳ, upstream, merge, xóa, tag",
|
||||
"title": "Nhánh và tag",
|
||||
"branches": "Nhánh",
|
||||
"tags": "Tag",
|
||||
"close": "Đóng",
|
||||
"searchBranches": "Tìm nhánh",
|
||||
"searchTags": "Tìm tag",
|
||||
"loading": "Đang tải…",
|
||||
"unavailable": "Thư mục này không phải là kho git.",
|
||||
"local": "Trên máy",
|
||||
"remote": "Remote · {{remote}}",
|
||||
"noMatch": "Không có nhánh nào khớp.",
|
||||
"truncated": "Chỉ hiện 1000 mục đầu của mỗi loại.",
|
||||
"merging": "Đang có merge, cherry-pick hoặc revert dở: hãy hoàn tất hoặc hủy nó trước khi đổi nhánh hay tag.",
|
||||
"current": "đang dùng",
|
||||
"protected": "được bảo vệ",
|
||||
"worktree": "worktree",
|
||||
"gone": "upstream đã mất",
|
||||
"ahead": "đi trước",
|
||||
"behind": "đi sau",
|
||||
"inSync": "đã đồng bộ",
|
||||
"newFrom": "Tạo nhánh từ đây",
|
||||
"newBranchName": "Tên nhánh mới",
|
||||
"switchToIt": "Chuyển sang nhánh đó",
|
||||
"switchDirty": "Hãy commit hoặc bỏ các thay đổi rồi mới chuyển nhánh được.",
|
||||
"create": "Tạo",
|
||||
"rename": "Đổi tên",
|
||||
"renameHint": "Đổi tên nhánh này (thiết lập upstream đi theo)",
|
||||
"renameTo": "Tên mới",
|
||||
"setUpstream": "Đặt upstream",
|
||||
"upstreamHint": "Theo dõi một nhánh remote: số commit đi trước/đi sau, pull và push sẽ dựa vào nó",
|
||||
"pickUpstream": "Chọn nhánh remote",
|
||||
"noRemoteBranches": "Chưa fetch nhánh remote nào",
|
||||
"unsetUpstream": "Bỏ upstream",
|
||||
"apply": "Áp dụng",
|
||||
"merge": "Merge",
|
||||
"mergeInto": "Merge vào {{current}}",
|
||||
"mergeHint": "Merge nhánh này vào {{current}} (cần cây làm việc sạch)",
|
||||
"mergeConfirm": "Merge {{branch}} vào {{current}}? Nếu có xung đột, bạn xử lý trong bảng Thay đổi hoặc hủy merge.",
|
||||
"delete": "Xóa",
|
||||
"deleteHint": "Xóa nhánh trên máy này (git branch -d)",
|
||||
"deleteConfirm": "Xóa nhánh {{branch}} trên máy? Nhánh đã merge hết nên không mất commit nào.",
|
||||
"deleteUnmerged": "Xóa nhánh {{branch}} trên máy? Nhánh chưa merge vào nhánh hiện tại, nên git sẽ từ chối trừ khi upstream của nó đã có đủ mọi commit.",
|
||||
"why.current": "Bạn đang ở nhánh này nên không xóa được.",
|
||||
"why.protected": "Nhánh được bảo vệ: không đổi tên hay xóa ở đây được.",
|
||||
"why.worktree": "Đang được mở ở worktree khác: {{path}}",
|
||||
"why.unmerged": "Chưa merge vào nhánh hiện tại: git sẽ không xóa trừ khi upstream của nó đã có đủ mọi commit.",
|
||||
"errLogin": "Remote cần đăng nhập. Hãy bấm Fetch hoặc Push trên thanh git một lần để đăng nhập (tick Ghi nhớ), rồi thử lại.",
|
||||
"errSubdir": "Nhánh và tag thuộc về cả kho: hãy làm việc này từ một workspace ở thư mục gốc của kho.",
|
||||
"tagNew": "Tạo tag",
|
||||
"tagName": "Tên tag, ví dụ v1.2.0",
|
||||
"tagFrom": "Đặt trên (nhánh, tag hoặc mã commit) — để trống là HEAD",
|
||||
"tagMessage": "Nội dung (không bắt buộc — có nội dung thì là tag có chú thích)",
|
||||
"tagCreateLight": "Tạo tag",
|
||||
"tagCreateAnnotated": "Tạo tag có chú thích",
|
||||
"cancel": "Hủy",
|
||||
"noTags": "Chưa có tag nào.",
|
||||
"annotated": "có chú thích",
|
||||
"tagPush": "Đẩy lên origin",
|
||||
"tagPushHint": "Chỉ đẩy đúng tag này (không dùng --tags, không ghi đè)",
|
||||
"tagPushConfirm": "Đẩy tag {{tag}} lên remote?",
|
||||
"tagDelete": "Xóa tag",
|
||||
"tagDeleteHint": "Chỉ xóa tag này trên máy",
|
||||
"tagDeleteConfirm": "Xóa tag {{tag}} trên máy? Không hoàn tác được ở đây; bản đã đẩy lên remote vẫn còn.",
|
||||
"done.create": "Đã tạo nhánh {{branch}}",
|
||||
"done.rename": "Đã đổi tên thành {{branch}}",
|
||||
"done.setUpstream": "Giờ đang theo dõi {{upstream}}",
|
||||
"done.unsetUpstream": "Đã bỏ upstream",
|
||||
"done.merge": "Đã merge {{branch}}",
|
||||
"done.delete": "Đã xóa nhánh {{branch}}",
|
||||
"done.tagCreate": "Đã tạo tag {{tag}}",
|
||||
"done.tagPush": "Đã đẩy tag {{tag}}",
|
||||
"done.tagDelete": "Đã xóa tag {{tag}}"
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"close": "Đóng",
|
||||
"cancel": "Huỷ",
|
||||
"loading": "Đang tải…",
|
||||
"pushed": "đã push",
|
||||
"forceAck": "Tôi hiểu: sau đó phải force-push nhánh này",
|
||||
"rewrites_other": "Thao tác này viết lại {{count}} commit.",
|
||||
"rewritesPushed_other": "Thao tác này viết lại {{count}} commit, trong đó {{pushed}} commit đã push nên sẽ phải force-push.",
|
||||
"doneTitle": "Xong.",
|
||||
"needsForcePushAfter": "Có commit đã push bị viết lại: khi chắc chắn, hãy push bằng Force push (with lease).",
|
||||
"undo": "Hoàn tác",
|
||||
"undoConfirm": "Bấm lần nữa để hoàn tác",
|
||||
"undoHint": "Đưa nhánh về lại {{sha}} (giữ nguyên thay đổi chưa commit).",
|
||||
"undone": "Đã hoàn tác: nhánh đã về chỗ cũ",
|
||||
"blocked.subdir": "Thao tác này đổi cả nhánh nên chỉ chạy được từ workspace ở thư mục gốc của repo.",
|
||||
"blocked.operation": "Đang có {{op}} dở dang: hãy hoàn tất hoặc huỷ trước.",
|
||||
"blocked.dirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước.",
|
||||
|
||||
"menu.label": "Công cụ lịch sử",
|
||||
"menu.rebase": "Rebase lên nhánh…",
|
||||
"menu.interactive": "Sửa các commit chưa push…",
|
||||
"menu.reflog": "Reflog",
|
||||
"menu.forcePush": "Force push (with lease)…",
|
||||
"menu.remotes": "Remote…",
|
||||
"menu.resetHere": "Reset nhánh về commit này…",
|
||||
"menu.interactiveHere": "Sửa các commit sau commit này…",
|
||||
|
||||
"merge.open": "Trình gộp 3 bên",
|
||||
"merge.title": "Gộp conflict — {{path}}",
|
||||
"merge.incoming": "Bên đến: {{ref}}",
|
||||
"merge.incomingRebase": "Bên đến (commit đang được áp lại): {{ref}}",
|
||||
"merge.incomingPlain": "Bên đến",
|
||||
"merge.current": "Hiện tại: {{branch}}",
|
||||
"merge.currentRebase": "Hiện tại (nhánh bạn đang rebase lên)",
|
||||
"merge.base": "Bản gốc chung",
|
||||
"merge.result": "Kết quả",
|
||||
"merge.sideGone": "Bên này đã xoá file",
|
||||
"merge.blockOf": "Conflict {{n}}/{{total}}",
|
||||
"merge.acceptCurrent": "Lấy bản hiện tại",
|
||||
"merge.acceptIncoming": "Lấy bản đến",
|
||||
"merge.bothCurrentFirst": "Lấy cả hai (hiện tại trước)",
|
||||
"merge.bothIncomingFirst": "Lấy cả hai (bản đến trước)",
|
||||
"merge.editBlock": "Sửa",
|
||||
"merge.editDone": "Xong",
|
||||
"merge.undoChoice": "Bỏ chọn",
|
||||
"merge.left_other": "Còn {{count}}/{{total}} conflict",
|
||||
"merge.allChosen": "Đã chọn xong mọi conflict",
|
||||
"merge.wholeMode": "Đang sửa toàn bộ kết quả",
|
||||
"merge.prev": "Conflict trước",
|
||||
"merge.next": "Conflict sau",
|
||||
"merge.showBase": "Xem bản gốc",
|
||||
"merge.editWhole": "Sửa cả file kết quả",
|
||||
"merge.backToBlocks": "Quay lại từng conflict",
|
||||
"merge.save": "Lưu kết quả",
|
||||
"merge.saved": "Đã giải quyết {{path}}",
|
||||
"merge.markersLeft": "Kết quả vẫn còn dấu conflict (<<<<<<< ======= >>>>>>>): hãy xử lý hết các khối trước.",
|
||||
"merge.stale": "File đã thay đổi trên đĩa từ lúc mở. Hãy đóng và mở lại.",
|
||||
"merge.binary": "Đây là file nhị phân: hãy chọn nguyên một bên ở màn hình conflict.",
|
||||
"merge.tooBig": "File quá lớn cho trình gộp: hãy chọn nguyên một bên hoặc sửa trong terminal.",
|
||||
"merge.noWorking": "Một bên đã xoá file này: hãy giữ hoặc xoá bằng Giữ của mình / Lấy bản kia.",
|
||||
"merge.unbalanced": "Dấu conflict trong file bị lệch: hãy sửa tay.",
|
||||
|
||||
"reflog.title": "Reflog",
|
||||
"reflog.of": "Các vị trí nhánh từng trỏ tới:",
|
||||
"reflog.hint": "Mọi chỗ HEAD hoặc nhánh từng trỏ tới, mới nhất ở trên. Commit bị mất do reset hay rebase vẫn còn ở đây: bấm Reset về đây để lấy lại.",
|
||||
"reflog.empty": "Chưa có gì được ghi lại.",
|
||||
"reflog.resetHere": "Reset về đây…",
|
||||
"reflog.more": "Xem thêm",
|
||||
|
||||
"rebase.title": "Rebase {{branch}} lên…",
|
||||
"rebase.onto": "Nhánh để rebase lên",
|
||||
"rebase.pick": "Chọn nhánh",
|
||||
"rebase.explain": "Các commit riêng của nhánh bạn sẽ được áp lại lần lượt lên trên nhánh bạn chọn. Nếu một commit bị conflict, rebase dừng lại và bảng Thay đổi sẽ có Tiếp tục, Bỏ qua và Huỷ.",
|
||||
"rebase.nothing": "Không có gì để áp lại: nhánh này không có commit nào mà {{onto}} chưa có.",
|
||||
"rebase.summary_other": "{{count}} commit sẽ được áp lại lên {{onto}} (bên đó có {{behind}} commit mới).",
|
||||
"rebase.run": "Rebase",
|
||||
"rebase.done": "Đã rebase lên {{onto}}",
|
||||
"rebase.stopped_other": "Rebase đã dừng: {{count}} file bị conflict. Xử lý xong rồi bấm Tiếp tục.",
|
||||
|
||||
"rebaseBanner.skip": "Bỏ qua commit",
|
||||
"rebaseBanner.skipHint": "Bỏ commit mà rebase đang dừng và làm tiếp commit sau",
|
||||
"rebaseBanner.finished": "Đã rebase xong",
|
||||
|
||||
"interactive.title": "Sửa commit",
|
||||
"interactive.explain": "Đổi thứ tự bằng các mũi tên và chọn cho từng commit: giữ nguyên, sửa nội dung message, gộp (squash hoặc fixup) vào commit phía trên, hoặc bỏ đi. Chưa có gì thay đổi cho tới khi bạn bấm Viết lại.",
|
||||
"interactive.merges": "Khoảng này có merge commit, hãy viết lại trong terminal.",
|
||||
"interactive.nothing": "Không có commit chưa push nào để sửa.",
|
||||
"interactive.action": "Làm gì với {{short}}",
|
||||
"interactive.actions.pick": "Giữ",
|
||||
"interactive.actions.reword": "Sửa message",
|
||||
"interactive.actions.squash": "Gộp vào commit trên",
|
||||
"interactive.actions.fixup": "Gộp vào commit trên (bỏ message)",
|
||||
"interactive.actions.drop": "Bỏ",
|
||||
"interactive.up": "Lên trên",
|
||||
"interactive.down": "Xuống dưới",
|
||||
"interactive.message": "Message của commit",
|
||||
"interactive.squashHint": "Để trống thì ghép cả hai message",
|
||||
"interactive.problem.first": "Commit đầu tiên được giữ không gộp được: phía trên nó không có gì.",
|
||||
"interactive.problem.allDropped": "Mọi commit đều bị bỏ: hãy dùng Reset nhánh về commit này.",
|
||||
"interactive.problem.reword": "Commit sửa message thì cần có message.",
|
||||
"interactive.run": "Viết lại",
|
||||
"interactive.runConfirm": "Bấm lần nữa để viết lại",
|
||||
"interactive.done": "Đã viết lại các commit",
|
||||
|
||||
"reset.title": "Reset {{branch}} về {{short}}",
|
||||
"reset.summary": "{{dropped}} commit rời khỏi {{branch}} và {{added}} commit được thêm vào.",
|
||||
"reset.already": "Nhánh đang ở đúng commit này rồi.",
|
||||
"reset.unrelated": "Commit này không nằm trên lịch sử của nhánh nên không reset về được ở đây.",
|
||||
"reset.mode": "File của bạn sẽ ra sao",
|
||||
"reset.modes.soft": "Soft",
|
||||
"reset.modes.mixed": "Mixed",
|
||||
"reset.modes.keep": "Keep",
|
||||
"reset.modes.hard": "Hard",
|
||||
"reset.modesHelp.soft": "Chỉ nhánh di chuyển; thay đổi của các commit bị bỏ vẫn nằm trong vùng stage.",
|
||||
"reset.modesHelp.mixed": "Nhánh di chuyển; các thay đổi đó vẫn còn trong file, chưa stage.",
|
||||
"reset.modesHelp.keep": "File đổi theo commit, nhưng git sẽ từ chối nếu việc đó ghi đè lên thay đổi chưa commit.",
|
||||
"reset.modesHelp.hard": "File đổi đúng y như commit. Chỉ chạy khi không có thay đổi chưa commit, nên không mất gì.",
|
||||
"reset.hardDirty": "Hard reset chỉ chạy khi không có thay đổi chưa commit. Hãy dùng Keep, hoặc commit/bỏ thay đổi trước.",
|
||||
"reset.typeBranch": "Gõ tên nhánh ({{branch}}) để xác nhận",
|
||||
"reset.pushed_other": "{{count}} commit rời khỏi nhánh đã được push, nên sẽ phải force-push.",
|
||||
"reset.run": "Reset ({{mode}})",
|
||||
"reset.done": "Đã reset nhánh về {{short}}",
|
||||
|
||||
"forcePush.title": "Force push (with lease)",
|
||||
"forcePush.where": "Push {{branch}} lên {{remote}}/{{remoteBranch}}, thay thế những gì đang có ở đó.",
|
||||
"forcePush.replaces": "Sẽ thay {{sha}} trên remote (nhánh của bạn đi trước {{ahead}}, đi sau {{behind}} commit).",
|
||||
"forcePush.newBranch": "Remote chưa có nhánh này.",
|
||||
"forcePush.dropsRemote_other": "{{count}} commit trên remote không có trong nhánh của bạn và sẽ biến mất khỏi đó.",
|
||||
"forcePush.protected": "Đây là nhánh được bảo vệ (main, master, develop…): server này tắt force-push cho nhánh đó.",
|
||||
"forcePush.lease": "With lease: push bị từ chối nếu có ai đã push lên nhánh remote kể từ lần fetch gần nhất của bạn.",
|
||||
"forcePush.run": "Force push",
|
||||
"forcePush.runConfirm": "Bấm lần nữa để force push",
|
||||
"forcePush.done": "Đã force push lên {{remote}}/{{branch}}",
|
||||
"forcePush.stale": "Nhánh remote đã thay đổi từ lần fetch gần nhất. Hãy fetch, xem trên đó có gì rồi thử lại.",
|
||||
|
||||
"remotes.title": "Remote",
|
||||
"remotes.none": "Repo này chưa có remote.",
|
||||
"remotes.default": "Nhánh mặc định: {{branch}}",
|
||||
"remotes.rename": "Đổi tên",
|
||||
"remotes.newName": "Tên mới",
|
||||
"remotes.remove": "Gỡ",
|
||||
"remotes.removeConfirm": "Bấm lần nữa để gỡ",
|
||||
"remotes.branches_other": "{{count}} nhánh",
|
||||
"remotes.deleteBranch": "Xoá trên remote",
|
||||
"remotes.deleteBranchConfirm": "Bấm lần nữa để xoá",
|
||||
"remotes.branchLocked": "Không xoá được nhánh mặc định và main/master/develop ở đây",
|
||||
"remotes.add": "Thêm remote",
|
||||
"remotes.name": "Tên",
|
||||
"remotes.url": "URL",
|
||||
"remotes.urlHint": "https:// (không kèm thông tin đăng nhập), ssh:// hoặc user@host:path.",
|
||||
"remotes.addButton": "Thêm",
|
||||
"remotes.added": "Đã thêm remote {{name}}",
|
||||
"remotes.renamed": "Đã đổi tên remote {{from}} thành {{to}}",
|
||||
"remotes.removed": "Đã gỡ remote {{name}}",
|
||||
"remotes.branchDeleted": "Đã xoá {{ref}} trên remote"
|
||||
}
|
||||
@@ -197,7 +197,7 @@
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
"gitBar.switchCurrent": "hiện tại",
|
||||
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
|
||||
@@ -215,7 +215,6 @@
|
||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||
"clone.nameLabel": "Tên thư mục",
|
||||
"clone.cloning": "Đang clone…",
|
||||
"clone.background": "Chạy nền",
|
||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
@@ -232,6 +231,41 @@
|
||||
"clone.phase.resolving": "Đang giải nén",
|
||||
"clone.phase.checkout": "Đang ghi file",
|
||||
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
|
||||
"cloneJobs.backgroundHint": "Việc clone chạy nền: bạn theo dõi ở góc màn hình, ở trang nào cũng được — kể cả sau khi tải lại trang.",
|
||||
"cloneJobs.starting": "Đang bắt đầu…",
|
||||
"cloneJobs.started": "Đã bắt đầu — tiến độ hiện ở góc màn hình.",
|
||||
"cloneJobs.cloning": "Đang clone {{name}}",
|
||||
"cloneJobs.queued": "Đang chờ trong hàng clone — thứ {{position}}",
|
||||
"cloneJobs.done": "Đã clone {{name}}",
|
||||
"cloneJobs.failedTitle": "Không clone được {{name}}",
|
||||
"cloneJobs.cancelledTitle": "Đã ngưng clone {{name}}",
|
||||
"cloneJobs.failed": "Clone bị lỗi.",
|
||||
"cloneJobs.interrupted": "AgentHub đã khởi động lại khi đang clone.",
|
||||
"cloneJobs.cancelled": "Bạn đã ngưng clone này.",
|
||||
"cloneJobs.cancelledToast": "Đã ngưng clone {{name}}.",
|
||||
"cloneJobs.failedToast": "Không clone được {{name}}: {{message}}",
|
||||
"cloneJobs.doneWorkspace": "Đã clone {{name}} vào {{path}} — mở thành workspace “{{workspace}}”.",
|
||||
"cloneJobs.doneSource": "Đã clone {{name}} vào {{path}} và thêm vào workspace.",
|
||||
"cloneJobs.openWorkspace": "Mở workspace",
|
||||
"cloneJobs.open": "Mở",
|
||||
"cloneJobs.retry": "Thử lại",
|
||||
"cloneJobs.retryLogin": "Thử lại với tài khoản này",
|
||||
"cloneJobs.stop": "Ngưng clone",
|
||||
"cloneJobs.stopConfirm": "Bấm lần nữa để ngưng",
|
||||
"cloneJobs.stopUnsupported": "Máy chủ AgentHub này chưa ngưng clone được — hãy cập nhật, hoặc chờ clone xong.",
|
||||
"cloneJobs.dismiss": "Đóng",
|
||||
"jobs.dock": "Việc chạy nền",
|
||||
"jobs.count_one": "{{count}} việc chạy nền",
|
||||
"jobs.count_other": "{{count}} việc chạy nền",
|
||||
"jobs.running_one": "{{count}} đang chạy",
|
||||
"jobs.running_other": "{{count}} đang chạy",
|
||||
"jobs.expand": "Xem các việc chạy nền",
|
||||
"uploads.stop": "Ngưng tải lên",
|
||||
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
|
||||
"uploads.stopping": "Đang ngưng…",
|
||||
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||
"uploads.interrupted": "Việc tải {{total}} tệp lên {{folder}} bị ngắt do tải lại trang — đã gửi {{done}} tệp.",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
@@ -343,6 +377,7 @@
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file",
|
||||
"row.actions": "Thao tác workspace",
|
||||
"row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác",
|
||||
"row.folderMissing": "Thư mục đã bị xóa — gỡ workspace này trong menu ⋯",
|
||||
"menu.rename": "Đổi tên",
|
||||
"menu.newHere": "Workspace mới tại đây…",
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
@@ -350,6 +385,14 @@
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Xem trang",
|
||||
"preview.title": "Xem trước trang web",
|
||||
"preview.address": "Địa chỉ trang",
|
||||
"preview.reload": "Tải lại",
|
||||
"preview.openTab": "Mở trong tab trình duyệt",
|
||||
"preview.badUrl": "Chỉ xem được trang http(s) khác với dashboard",
|
||||
"preview.empty": "Nhập địa chỉ, hoặc bấm một link localhost trong chat hay terminal.",
|
||||
"preview.loginHint": "Trang trắng? Trang có thể chặn nhúng hoặc cần đăng nhập — hãy mở trong tab trình duyệt.",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||
"replay.send": "Gửi",
|
||||
@@ -535,6 +578,8 @@
|
||||
"changes.discardTicked": "Bỏ ({{count}})",
|
||||
"changes.discardTickedTitle": "Bỏ các thay đổi đã chọn?",
|
||||
"changes.discardTickedConfirm": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||
"changes.discardTickedConfirmTracked": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||
"changes.discardTickedConfirmNew": "{{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
@@ -567,7 +612,6 @@
|
||||
"fx.newFile": "File mới",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Tải file lên",
|
||||
"fx.uploadFolder": "Tải cả thư mục lên",
|
||||
"fx.download": "Tải về",
|
||||
@@ -590,6 +634,7 @@
|
||||
"fx.dropHint": "Kéo file hoặc thư mục vào đây để tải lên · kéo vào một thư mục để di chuyển (Ctrl: sao chép)",
|
||||
"fx.uploading": "Đang tải lên {{done}}/{{total}}…",
|
||||
"fx.uploaded": "Đã tải lên {{count}} file.",
|
||||
"fx.gitSkipped": "Đã bỏ qua {{count}} file trong .git — không tải gì vào .git.",
|
||||
"fx.uploadFailed": "Không tải lên được {{name}}: {{message}}",
|
||||
"fx.tooBig": "{{size}} vượt quá giới hạn một lần tải lên ({{max}}). Hãy tải ít file hơn mỗi lần.",
|
||||
"fx.trashed": "Đã chuyển {{count}} mục vào Thùng rác.",
|
||||
@@ -675,5 +720,38 @@
|
||||
"menu.newGroupWith": "Nhóm mới…",
|
||||
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
|
||||
"tree.groupFoldAll": "Thu gọn mọi nhóm",
|
||||
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
|
||||
"tree.groupUnfoldAll": "Mở rộng mọi nhóm",
|
||||
"gitBar.switchDirtyHint": "{{count}} file chưa commit: bạn sẽ được hỏi mang theo hay cất chúng lại",
|
||||
"gitBar.choiceTitle": "Chuyển sang {{branch}}",
|
||||
"gitBar.choiceMessage": "Bạn có {{count}} file chưa commit. Chuyển sang {{branch}} và…",
|
||||
"gitBar.choiceCarry": "Mang thay đổi theo",
|
||||
"gitBar.choiceCarryHint": "Chuyển nhánh và giữ nguyên thay đổi chưa commit trong thư mục (git từ chối nếu một file sẽ bị ghi đè)",
|
||||
"gitBar.choiceShelve": "Cất rồi chuyển",
|
||||
"gitBar.choiceShelveHint": "Cất thay đổi vào một shelf rồi chuyển nhánh; khôi phục lại sau ở mục Shelves",
|
||||
"gitBar.carryConflict": "Không thể mang thay đổi sang {{branch}}: git sẽ ghi đè các file này. Cất chúng lại thay vì vậy?",
|
||||
"gitBar.branchCreateSwitch": "Tạo & chuyển",
|
||||
"gitBar.branchCreateSwitchHint": "Tạo nhánh này và chuyển sang; thay đổi chưa commit được mang theo",
|
||||
"gitBar.shelvedOn": "Thay đổi trên {{branch}} đã được cất vào shelf",
|
||||
"gitBar.restoreOfferTitle": "Khôi phục thay đổi đã cất?",
|
||||
"gitBar.restoreOffer": "{{branch}} có {{count}} shelf. Khôi phục shelf mới nhất ({{files}} file) ngay?",
|
||||
"shelves.title": "Shelves ({{count}})",
|
||||
"shelves.files": "{{count}} file",
|
||||
"shelves.restore": "Khôi phục",
|
||||
"shelves.restoreHint": "Đưa các thay đổi này trở lại (cần thư mục sạch); shelf được xoá khi áp dụng không xung đột",
|
||||
"shelves.delete": "Xoá",
|
||||
"shelves.deleteConfirm": "Xoá hẳn?",
|
||||
"shelves.deleteHint": "Xoá shelf này cùng các thay đổi trong nó (bấm hai lần)",
|
||||
"shelves.deleted": "Đã xoá shelf",
|
||||
"shelves.restored": "Đã khôi phục thay đổi đã cất",
|
||||
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
|
||||
"shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf",
|
||||
"shelves.shelveTicked": "Cất ({{count}})",
|
||||
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
|
||||
"shelves.shelveApply": "Cất {{count}} file",
|
||||
"shelves.notePlaceholder": "Ghi chú cho shelf này (tuỳ chọn)…",
|
||||
"shelves.cancel": "Huỷ",
|
||||
"shelves.shelved": "Đã cất {{count}} file",
|
||||
"shelves.agentTitle": "Một agent đang làm việc ở đây",
|
||||
"shelves.agentMessage": "Một agent trong workspace này đang làm việc. Thao tác này di chuyển file ngay dưới nó, agent có thể bị nhầm hoặc ghi đè lên. Vẫn tiếp tục?",
|
||||
"shelves.agentConfirm": "Tiếp tục"
|
||||
}
|
||||
|
||||
@@ -899,11 +899,13 @@
|
||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||
"stepCopy": "Copy public key.",
|
||||
"awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.",
|
||||
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:",
|
||||
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây — một key ID dùng được cho mọi region:",
|
||||
"awsKeyId": "SSH key ID",
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Lưu",
|
||||
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||
"awsSavedAll": "Đã lưu SSH key ID cho mọi region",
|
||||
"awsSavedNamed": "Đã lưu SSH key ID cho {{name}}",
|
||||
"awsAllRegions": "Mọi region",
|
||||
"awsUseAll": "Dùng cho mọi region",
|
||||
"otherTitle": "Host SSH khác (nâng cao)",
|
||||
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||
"otherHost": "Host",
|
||||
@@ -917,7 +919,7 @@
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabOther": "Host khác",
|
||||
"addKey": "Thêm key khác",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file Pins the cause of "after Switch branch the Changes panel still shows
|
||||
* the old branch": `request()` shares one in-flight GET between callers, and
|
||||
* the panel's re-read after a switch joined a poll that had STARTED before
|
||||
* the switch finished — so it got the old branch's answer. A write now
|
||||
* closes every in-flight GET to new joiners (before it is sent and once it
|
||||
* settles), and `gitAction` announces itself (`agenthub:git-changed`) so the
|
||||
* views that never hear the bar's callback read again too.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { api } from "../api";
|
||||
import { GIT_CHANGED_EVENT } from "../gitChanged";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function json(body: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "application/json" },
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
describe("a git write and the reads around it", () => {
|
||||
it("a read asked after a switch never joins a read started before it", async () => {
|
||||
let releaseOld: (v: unknown) => void = () => {};
|
||||
const spy = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") return Promise.resolve(json({ ok: true, action: "switch" }));
|
||||
// The first GET (a poll) hangs until released, answering the OLD branch.
|
||||
if (spy.mock.calls.filter((c) => !c[1]?.method).length === 1) {
|
||||
return new Promise((r) => {
|
||||
releaseOld = () => r(json({ branch: "old" }));
|
||||
});
|
||||
}
|
||||
return Promise.resolve(json({ branch: "new" }));
|
||||
});
|
||||
vi.stubGlobal("fetch", spy);
|
||||
|
||||
const poll = api.lanes.git(3);
|
||||
await api.lanes.gitAction(3, "switch", { branch: "new" });
|
||||
const after = api.lanes.git(3);
|
||||
releaseOld(undefined);
|
||||
expect(await poll).toEqual({ branch: "old" });
|
||||
expect(await after).toEqual({ branch: "new" });
|
||||
expect(spy.mock.calls.filter((c) => !c[1]?.method)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("still shares a read between callers when no write came between them", async () => {
|
||||
const spy = vi.fn(() => Promise.resolve(json({ branch: "x" })));
|
||||
vi.stubGlobal("fetch", spy);
|
||||
await Promise.all([api.lanes.git(4), api.lanes.git(4)]);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("announces every git write for its workspace, a failed one included", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const on = (e: Event) => seen.push((e as CustomEvent).detail);
|
||||
window.addEventListener(GIT_CHANGED_EVENT, on);
|
||||
try {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve(json({ ok: true })))
|
||||
);
|
||||
await api.lanes.gitAction({ lane: 5, session: "s" }, "shelve", {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
headers: { get: () => "application/json" },
|
||||
json: async () => ({ error: { code: "ESHELVED", message: "x" } }),
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(api.lanes.gitAction(5, "switch", { branch: "b" })).rejects.toThrow();
|
||||
} finally {
|
||||
window.removeEventListener(GIT_CHANGED_EVENT, on);
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
{ laneId: 5, action: "shelve" },
|
||||
{ laneId: 5, action: "switch" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file openInPreview.test.ts
|
||||
* @description The one link rule: which URLs the Preview takes (http(s),
|
||||
* never the dashboard's origin, `0.0.0.0` read as `localhost`), that a local
|
||||
* link goes to the session panel only when one answers, and that a
|
||||
* Ctrl/Cmd click keeps the browser's own tab.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OPEN_IN_PREVIEW_EVENT,
|
||||
isLoopbackUrl,
|
||||
normalizePreviewUrl,
|
||||
onLinkClick,
|
||||
openInPreview,
|
||||
type OpenInPreview,
|
||||
} from "../openInPreview";
|
||||
|
||||
const SELF = "http://localhost:4820";
|
||||
|
||||
describe("normalizePreviewUrl", () => {
|
||||
it("adds http:// to a bare host:port and reads 0.0.0.0 as localhost", () => {
|
||||
expect(normalizePreviewUrl("localhost:3000", SELF)).toBe("http://localhost:3000/");
|
||||
expect(normalizePreviewUrl("http://0.0.0.0:5173/a", SELF)).toBe("http://localhost:5173/a");
|
||||
});
|
||||
it("refuses the dashboard's own origin, other schemes and credentials", () => {
|
||||
expect(normalizePreviewUrl("http://localhost:4820/settings", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("javascript:alert(1)", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("file:///etc/passwd", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("http://u:p@localhost:3000", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl(" ", SELF)).toBeNull();
|
||||
});
|
||||
it("accepts an external https page", () => {
|
||||
expect(normalizePreviewUrl("https://example.com/x", SELF)).toBe("https://example.com/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoopbackUrl", () => {
|
||||
it("is true only for this machine's hosts", () => {
|
||||
expect(isLoopbackUrl("http://localhost:3000")).toBe(true);
|
||||
expect(isLoopbackUrl("http://127.0.0.1:8080/x")).toBe(true);
|
||||
expect(isLoopbackUrl("http://[::1]:3000")).toBe(true);
|
||||
expect(isLoopbackUrl("https://github.com")).toBe(false);
|
||||
expect(isLoopbackUrl("not a url")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openInPreview", () => {
|
||||
let off: (() => void) | null = null;
|
||||
afterEach(() => off?.());
|
||||
const listen = (take: boolean) => {
|
||||
const seen: string[] = [];
|
||||
const fn = (e: Event) => {
|
||||
const d = (e as CustomEvent<OpenInPreview>).detail;
|
||||
seen.push(d.url);
|
||||
if (take) d.handled = true;
|
||||
};
|
||||
window.addEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||
off = () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||
return seen;
|
||||
};
|
||||
|
||||
it("hands a local page to a listening panel", () => {
|
||||
const seen = listen(true);
|
||||
expect(openInPreview("http://localhost:3000/login")).toBe(true);
|
||||
expect(seen).toEqual(["http://localhost:3000/login"]);
|
||||
});
|
||||
it("falls back when no panel takes it, and never sends an external page", () => {
|
||||
const seen = listen(false);
|
||||
expect(openInPreview("http://localhost:3000")).toBe(false);
|
||||
expect(openInPreview("https://github.com/login")).toBe(false);
|
||||
expect(seen).toEqual(["http://localhost:3000/"]);
|
||||
});
|
||||
it("leaves a Ctrl/Cmd click to the browser", () => {
|
||||
listen(true);
|
||||
const preventDefault = vi.fn();
|
||||
const base = { ctrlKey: false, metaKey: false, shiftKey: false, button: 0, preventDefault };
|
||||
onLinkClick({ ...base, ctrlKey: true }, "http://localhost:3000");
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
onLinkClick(base, "http://localhost:3000");
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -432,6 +432,14 @@ describe("readRunBusy", () => {
|
||||
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||||
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
// Claude's spinner with the footer's hint gone (a narrow pane, a tip).
|
||||
rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
rows = ["✻ Thinking… (1m 3s)"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
// The line a finished turn leaves is not.
|
||||
rows = ["✻ Cooked for 1m 2s", "❯ "];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||
// Claude's permission footer is not a turn at work.
|
||||
rows = ["Esc to cancel · Tab to amend"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||
|
||||
+391
-27
@@ -434,10 +434,24 @@ import type {
|
||||
ReportInput,
|
||||
ReportRun,
|
||||
} from "./types";
|
||||
import type {
|
||||
BlameAt,
|
||||
CommitDetails,
|
||||
CommitPatch,
|
||||
CompareFile,
|
||||
CompareResult,
|
||||
FileHistoryPage,
|
||||
PickAction,
|
||||
PickPlan,
|
||||
PickResult,
|
||||
PickSequence,
|
||||
TreeAt,
|
||||
} from "./gitHistory";
|
||||
|
||||
import { activeSourcesParam } from "./dataScope";
|
||||
import i18n from "i18next";
|
||||
import { applyRange, type DateRange } from "./dateRange";
|
||||
import { emitGitChanged } from "./gitChanged";
|
||||
|
||||
// Root path all endpoint paths are appended to. Kept relative (no host) so the
|
||||
// same client bundle works behind the Vite dev proxy and in same-origin prod.
|
||||
@@ -995,16 +1009,23 @@ export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | n
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
* components mounting, a WS event fanning out to many listeners) share one
|
||||
* fetch and one parsed result. Deleted as soon as it settles, so this is
|
||||
* dedupe, never a cache. Callers must treat the result as read-only. */
|
||||
* dedupe, never a cache. Callers must treat the result as read-only.
|
||||
* A WRITE closes every one of them to new joiners, before it is sent and
|
||||
* again once it settles: a read asked after a write must see the write, and
|
||||
* joining a poll that started before it handed back the old state (after a
|
||||
* branch switch, the Changes panel kept showing the branch just left). */
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
export function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
// Only a plain GET: a body, headers or an AbortSignal belongs to one caller.
|
||||
const plainGet =
|
||||
!options ||
|
||||
(Object.keys(options).every((k) => k === "method") &&
|
||||
(options.method ?? "GET").toUpperCase() === "GET");
|
||||
if (!plainGet) return send<T>(path, options);
|
||||
if (!plainGet) {
|
||||
inflight.clear();
|
||||
return send<T>(path, options).finally(() => inflight.clear());
|
||||
}
|
||||
const hit = inflight.get(path);
|
||||
if (hit) return hit as Promise<T>;
|
||||
const p = send<T>(path, options).finally(() => inflight.delete(path));
|
||||
@@ -1048,6 +1069,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
auth: body?.error?.auth as GitAuthNeeded | undefined,
|
||||
// ESESSIONLIVE from a workspace start: the pane already holding it.
|
||||
runId: body?.error?.run_id as string | undefined,
|
||||
// ECARRYCONFLICT from a lane git switch: the files it would overwrite.
|
||||
files: body?.error?.files as string[] | undefined,
|
||||
// EEXISTS from the File Explorer: the names already taken.
|
||||
names: body?.error?.names as string[] | undefined,
|
||||
// ETOOBIG from a prompt drop: the server's own limit.
|
||||
@@ -1100,7 +1123,21 @@ export type GitTarget = number | { lane: number; session?: string; source?: numb
|
||||
export type GitSource = number | string;
|
||||
|
||||
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
/**
|
||||
* A git write, then `agenthub:git-changed` for its workspace — success or
|
||||
* failure, since a refused shelve or a conflicted restore may still have
|
||||
* moved files — so every view of that workspace reads again (gitChanged.ts).
|
||||
*/
|
||||
function requestAndAnnounce<T>(
|
||||
path: string,
|
||||
options: RequestInit,
|
||||
laneId: number,
|
||||
action: string
|
||||
): Promise<T> {
|
||||
return request<T>(path, options).finally(() => emitGitChanged(laneId, action));
|
||||
}
|
||||
|
||||
export function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
const params = new URLSearchParams(q);
|
||||
const lane = typeof target === "number" ? target : target.lane;
|
||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||
@@ -1128,9 +1165,22 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
|
||||
const sendRaw = <T>(
|
||||
url: string,
|
||||
payload: Blob,
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const aborted = () => Object.assign(new Error("aborted"), { code: "EABORT" });
|
||||
if (signal?.aborted) {
|
||||
reject(aborted());
|
||||
return;
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Stop: the request is dropped mid-stream, the caller's loop sees EABORT.
|
||||
signal?.addEventListener("abort", () => xhr.abort(), { once: true });
|
||||
xhr.onabort = () => reject(aborted());
|
||||
xhr.open("POST", `${BASE}${url}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
@@ -1204,6 +1254,70 @@ export interface FsTrashItem {
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
|
||||
/** A local branch as the Branches panel lists it (`GET …/git/refs`). */
|
||||
export interface GitLocalBranch {
|
||||
name: string;
|
||||
sha: string;
|
||||
current: boolean;
|
||||
upstream: string | null;
|
||||
/** Null without an upstream, or when it is `gone`. */
|
||||
ahead: number | null;
|
||||
behind: number | null;
|
||||
gone: boolean;
|
||||
subject: string;
|
||||
date: string | null;
|
||||
/** Merged into HEAD — what `git branch -d` checks. */
|
||||
merged: boolean;
|
||||
/** Another worktree holding it, which `branch -d` refuses. */
|
||||
worktree: string | null;
|
||||
protected: boolean;
|
||||
}
|
||||
/** A fetched remote-tracking branch (`origin/x`). */
|
||||
export interface GitRemoteBranch {
|
||||
name: string;
|
||||
branch: string;
|
||||
sha: string;
|
||||
subject: string;
|
||||
date: string | null;
|
||||
}
|
||||
/** A tag: its target commit and, when annotated, its message and tagger. */
|
||||
export interface GitTag {
|
||||
name: string;
|
||||
sha: string | null;
|
||||
annotated: boolean;
|
||||
message: string | null;
|
||||
tagger: string | null;
|
||||
date: string | null;
|
||||
}
|
||||
export interface GitRefs {
|
||||
available: boolean;
|
||||
current: string | null;
|
||||
head: string | null;
|
||||
local: GitLocalBranch[];
|
||||
remotes: { remote: string; branches: GitRemoteBranch[] }[];
|
||||
tags: GitTag[];
|
||||
truncated: boolean;
|
||||
}
|
||||
export type GitRefAction =
|
||||
| "branch-create"
|
||||
| "branch-rename"
|
||||
| "branch-upstream"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "tag-create"
|
||||
| "tag-delete"
|
||||
| "tag-push";
|
||||
export interface GitRefBody {
|
||||
branch?: string;
|
||||
name?: string;
|
||||
from?: string;
|
||||
switch?: boolean;
|
||||
upstream?: string;
|
||||
tag?: string;
|
||||
message?: string;
|
||||
confirm?: boolean;
|
||||
}
|
||||
|
||||
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
@@ -1215,6 +1329,21 @@ export function gitTarget(
|
||||
return session ? { lane: laneId, session } : laneId;
|
||||
}
|
||||
|
||||
/** A shelf: changes set aside by AgentHub (a git stash entry it made). */
|
||||
export interface GitShelf {
|
||||
/** `stash@{n}` right now — informational; writes address `sha`. */
|
||||
ref: string;
|
||||
/** The stash commit; what unshelve and shelf-drop take. */
|
||||
sha: string;
|
||||
/** The branch it was shelved on. */
|
||||
branch: string;
|
||||
note: string;
|
||||
/** ISO time it was shelved. */
|
||||
createdAt: string | null;
|
||||
/** Its files; `??` = an untracked file. */
|
||||
files: { path: string; status: string }[];
|
||||
}
|
||||
|
||||
/** A file the dashboard machine's file manager holds, by its own path. */
|
||||
export interface FileManagerFile {
|
||||
path: string;
|
||||
@@ -3162,12 +3291,14 @@ export const api = {
|
||||
path: string,
|
||||
file: Blob,
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
sendRaw<{ path: string; skipped?: boolean }>(
|
||||
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
|
||||
file,
|
||||
onProgress
|
||||
onProgress,
|
||||
signal
|
||||
),
|
||||
/**
|
||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||
@@ -3179,7 +3310,8 @@ export const api = {
|
||||
dir: string,
|
||||
files: { file: Blob; rel: string }[],
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const head = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
@@ -3195,7 +3327,8 @@ export const api = {
|
||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
||||
onProgress
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
},
|
||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||
@@ -3231,11 +3364,23 @@ export const api = {
|
||||
* the user): for the host itself, or with `alias` one more account on it
|
||||
* (`ssh://<alias>/…`); `key` = the only key it uses.
|
||||
*/
|
||||
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
|
||||
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias. */
|
||||
removeSshUser: (from: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
|
||||
/** `allRegions`: AWS CodeCommit's default login, for every region at once. */
|
||||
setSshUser: (body: {
|
||||
host: string;
|
||||
user: string;
|
||||
alias?: string;
|
||||
key?: string;
|
||||
allRegions?: boolean;
|
||||
}) => request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias;
|
||||
* `allRegions` (a CodeCommit host): every region's login of `user`. */
|
||||
removeSshUser: (from: string, opts?: { allRegions: true; user: string }) =>
|
||||
request<GitIdentity>(
|
||||
`/me/git/ssh-user/${encodeURIComponent(from)}${
|
||||
opts ? `?allRegions=1&user=${encodeURIComponent(opts.user)}` : ""
|
||||
}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
@@ -4067,10 +4212,40 @@ export const api = {
|
||||
partial?: boolean;
|
||||
/** This clone's name for {@link lanes.cloneProgress}. */
|
||||
progressToken?: string;
|
||||
/** Run as a server-side job: 202 `{ job }` once the checks pass (an
|
||||
* older server ignores it and answers when the clone is done). */
|
||||
background?: boolean;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
|
||||
request<{ lane: Lane; created: boolean; path: string } | { job: CloneJob }>(
|
||||
"/workspaces/clone",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
/** GET /api/lanes/clone/jobs — the caller's background clones, newest
|
||||
* first, kept by the server across reloads and restarts. */
|
||||
cloneJobs: () => request<{ jobs: CloneJob[] }>("/workspaces/clone/jobs"),
|
||||
/** DELETE /api/lanes/clone/jobs/:id — forget a finished job (409 EBUSY while it runs). */
|
||||
dismissCloneJob: (id: string) =>
|
||||
request<{ ok: true }>(`/workspaces/clone/jobs/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
/** POST /api/lanes/clone/jobs/:id/retry — a failed or interrupted job
|
||||
* again, optionally with a login; answers the NEW job (the old one goes). */
|
||||
retryCloneJob: (
|
||||
id: string,
|
||||
credentials?: { username: string; password: string; remember?: boolean }
|
||||
) =>
|
||||
request<{ job: CloneJob }>(`/workspaces/clone/jobs/${encodeURIComponent(id)}/retry`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(credentials ? { credentials } : {}),
|
||||
}),
|
||||
/** POST /api/lanes/clone/jobs/:id/cancel — stop a queued or running clone
|
||||
* (`state: "cancelled"`); 409 EDONE once it finished, 404 on an older server. */
|
||||
cancelCloneJob: (id: string) =>
|
||||
request<{ job: CloneJob }>(`/workspaces/clone/jobs/${encodeURIComponent(id)}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/clone/progress/:token — a clone's place in the
|
||||
@@ -4235,13 +4410,108 @@ export const api = {
|
||||
/** GET /api/lanes/:id/git/blame — Annotate: each line's last commit. */
|
||||
gitBlame: (id: GitTarget, path: string) =>
|
||||
request<LaneBlame>(gitUrl(id, "/blame", new URLSearchParams({ path }))),
|
||||
gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
|
||||
gitLog: (
|
||||
id: GitTarget,
|
||||
opts: {
|
||||
limit?: number;
|
||||
file?: string;
|
||||
ref?: string;
|
||||
/** The page's start (the graph's paging). */
|
||||
skip?: number;
|
||||
/** Every branch, remote branch and tag. */
|
||||
all?: boolean;
|
||||
/** Search: message text (or a SHA prefix), author, path. */
|
||||
q?: string;
|
||||
author?: string;
|
||||
path?: string;
|
||||
} = {}
|
||||
) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.limit) q.set("limit", String(opts.limit));
|
||||
if (opts.file) q.set("file", opts.file);
|
||||
// Another branch's history (cherry-pick's source); absent = HEAD's.
|
||||
if (opts.ref) q.set("ref", opts.ref);
|
||||
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
|
||||
if (opts.skip) q.set("skip", String(opts.skip));
|
||||
if (opts.all) q.set("all", "1");
|
||||
if (opts.q) q.set("q", opts.q);
|
||||
if (opts.author) q.set("author", opts.author);
|
||||
if (opts.path) q.set("path", opts.path);
|
||||
return request<{
|
||||
available: boolean;
|
||||
commits: LaneCommit[];
|
||||
hasMore?: boolean;
|
||||
filtered?: boolean;
|
||||
}>(gitUrl(id, "/log", q));
|
||||
},
|
||||
/** The history reads and the multi-commit pick (server/lib/git-history.js). */
|
||||
gitHistory: {
|
||||
commit: (id: GitTarget, sha: string) =>
|
||||
request<CommitDetails | { available: false }>(
|
||||
gitUrl(id, "/commit", new URLSearchParams({ sha }))
|
||||
),
|
||||
patch: (id: GitTarget, sha: string) =>
|
||||
request<CommitPatch | { available: false }>(
|
||||
gitUrl(id, "/patch", new URLSearchParams({ sha }))
|
||||
),
|
||||
fileHistory: (id: GitTarget, path: string, opts: { skip?: number; limit?: number } = {}) => {
|
||||
const q = new URLSearchParams({ path });
|
||||
if (opts.skip) q.set("skip", String(opts.skip));
|
||||
if (opts.limit) q.set("limit", String(opts.limit));
|
||||
return request<FileHistoryPage | { available: false }>(gitUrl(id, "/file-history", q));
|
||||
},
|
||||
blameAt: (id: GitTarget, path: string, rev = "work") =>
|
||||
request<BlameAt | { available: false }>(
|
||||
gitUrl(id, "/blame-at", new URLSearchParams({ path, rev }))
|
||||
),
|
||||
treeAt: (id: GitTarget, rev: string, path = "") =>
|
||||
request<TreeAt | { available: false }>(
|
||||
gitUrl(id, "/tree-at", new URLSearchParams({ rev, path }))
|
||||
),
|
||||
compare: (id: GitTarget, a: string, b: string, mode: "three" | "two" = "three") =>
|
||||
request<CompareResult | { available: false }>(
|
||||
gitUrl(id, "/compare", new URLSearchParams({ a, b, mode }))
|
||||
),
|
||||
compareFile: (
|
||||
id: GitTarget,
|
||||
a: string,
|
||||
b: string,
|
||||
mode: "three" | "two",
|
||||
path: string,
|
||||
from?: string
|
||||
) => {
|
||||
const q = new URLSearchParams({ a, b, mode, path });
|
||||
if (from) q.set("from", from);
|
||||
return request<CompareFile | { available: false }>(gitUrl(id, "/compare", q));
|
||||
},
|
||||
pickPlan: (id: GitTarget, op: "cherry-pick" | "revert", shas: string[]) =>
|
||||
request<PickPlan | { available: false }>(
|
||||
gitUrl(id, "/pick-plan", new URLSearchParams({ op, shas: shas.join(",") }))
|
||||
),
|
||||
sequence: (id: GitTarget) =>
|
||||
request<PickSequence | { available: false }>(gitUrl(id, "/sequence")),
|
||||
/** POST cherry-pick / revert with `shas` (git-pick.js). */
|
||||
// Both announce `agenthub:git-changed`, like every other git write.
|
||||
pick: (id: GitTarget, op: "cherry-pick" | "revert", shas: string[]) =>
|
||||
requestAndAnnounce<PickResult>(
|
||||
gitUrl(id, `/${op}`),
|
||||
{ method: "POST", body: JSON.stringify({ shas }) },
|
||||
typeof id === "number" ? id : id.lane,
|
||||
op
|
||||
),
|
||||
/** POST pick-continue / pick-skip / pick-abort / undo-pick. */
|
||||
act: (
|
||||
id: GitTarget,
|
||||
action: PickAction,
|
||||
body: { confirm?: boolean; from?: string; to?: string } = {}
|
||||
) =>
|
||||
requestAndAnnounce<
|
||||
PickResult & { undone?: { sha: string; short: string; subject: string }[] }
|
||||
>(
|
||||
gitUrl(id, `/${action}`),
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
typeof id === "number" ? id : id.lane,
|
||||
action
|
||||
),
|
||||
},
|
||||
/**
|
||||
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
|
||||
@@ -4338,6 +4608,25 @@ export const api = {
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(gitUrl(id, "/branches")),
|
||||
/** GET /api/lanes/:id/git/refs — every branch and tag, for the Branches panel. */
|
||||
gitRefs: (id: GitTarget) => request<GitRefs>(gitUrl(id, "/refs")),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/:action — a branch & tag write (server/lib/git-refs.js):
|
||||
* `branch-create {branch, from?, switch?}`, `branch-rename {branch, name}`,
|
||||
* `branch-upstream {branch, upstream?}` (none = unset), `tag-create {tag, message?, from?}`,
|
||||
* `tag-delete {tag, confirm: true}`, `tag-push {tag}`, plus `merge`/`branch-delete {branch}`.
|
||||
*/
|
||||
gitRefAction: (id: GitTarget, action: GitRefAction, body: GitRefBody) =>
|
||||
request<{ action: string; ok: true; output: string; branch?: string; tag?: string }>(
|
||||
gitUrl(id, `/${action}`),
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/:id/git/shelves — the stash entries AgentHub made
|
||||
* (shelves), newest first. Other stashes are never listed.
|
||||
*/
|
||||
gitShelves: (id: GitTarget) =>
|
||||
request<{ available: boolean; shelves: GitShelf[] }>(gitUrl(id, "/shelves")),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
|
||||
* written by the user's `claude` CLI (no tools, nothing persisted).
|
||||
@@ -4352,7 +4641,7 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
|
||||
* `branch-delete` or `publish`.
|
||||
* `branch-delete`, `publish`, `shelve`, `unshelve` or `shelf-drop`.
|
||||
* @param body `{message, only}` for commit; `{branch, create}` for switch;
|
||||
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
|
||||
* `{paths, confirm: true}` for discard — which is irreversible, so the
|
||||
@@ -4384,8 +4673,17 @@ export const api = {
|
||||
| "revert"
|
||||
| "undo-commit"
|
||||
| "amend"
|
||||
| "ignore",
|
||||
| "ignore"
|
||||
| "shelve"
|
||||
| "unshelve"
|
||||
| "shelf-drop",
|
||||
body: {
|
||||
/** switch: what happens to uncommitted changes (absent = refuse a dirty tree). */
|
||||
changes?: "carry" | "shelve";
|
||||
/** branch-create: also switch to the new branch, carrying the changes. */
|
||||
switch?: boolean;
|
||||
/** shelve, switch with changes: "shelve": a one-line note on the shelf. */
|
||||
note?: string;
|
||||
/** cherry-pick / revert: the commit. */
|
||||
sha?: string;
|
||||
url?: string;
|
||||
@@ -4408,9 +4706,25 @@ export const api = {
|
||||
credentials?: GitCredentialInput & { remember: boolean };
|
||||
} = {}
|
||||
) =>
|
||||
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
|
||||
requestAndAnnounce<{
|
||||
action: string;
|
||||
ok: true;
|
||||
output: string;
|
||||
branch?: string;
|
||||
conflicts?: string[];
|
||||
/** switch: the TARGET branch's shelves, to offer back. */
|
||||
shelves?: GitShelf[];
|
||||
/** switch with changes: "shelve": the shelf it made. */
|
||||
shelved?: GitShelf;
|
||||
/** shelve: the shelf it made. */
|
||||
shelf?: GitShelf;
|
||||
/** unshelve: true when conflicts kept the shelf. */
|
||||
kept?: boolean;
|
||||
}>(
|
||||
gitUrl(id, `/${action}`),
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
typeof id === "number" ? id : id.lane,
|
||||
action
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
|
||||
@@ -4487,12 +4801,17 @@ export const api = {
|
||||
/** Blobless clone, like {@link lanes.clone}'s `partial`. */
|
||||
partial?: boolean;
|
||||
progressToken?: string;
|
||||
/** A server-side job, like {@link lanes.clone}'s `background`. */
|
||||
background?: boolean;
|
||||
}
|
||||
) =>
|
||||
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
request<(LaneSourcesView & { path: string }) | { job: CloneJob }>(
|
||||
`/workspaces/${id}/sources/clone`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||
addFolder: (id: number, path: string) =>
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
|
||||
@@ -4720,11 +5039,21 @@ export interface Artifact {
|
||||
suggestedForMe?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** When its content last changed (a new snapshot); a page or plugin: when it was added. */
|
||||
versionAt: string;
|
||||
}
|
||||
|
||||
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
||||
export interface ArtifactInstalls {
|
||||
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
|
||||
items: {
|
||||
user: string | null;
|
||||
mine: boolean;
|
||||
at: string;
|
||||
replaced: boolean;
|
||||
/** Skills: where that install's copy stands against the newest version now. */
|
||||
state?: "latest" | "outdated" | "removed" | "unknown";
|
||||
paths?: string[];
|
||||
}[];
|
||||
/** Installs counted before the list was kept. */
|
||||
unrecorded: number;
|
||||
}
|
||||
@@ -4935,6 +5264,9 @@ export interface GitIdentity {
|
||||
* it uses, null = every key. Absent from an older server.
|
||||
*/
|
||||
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
|
||||
/** The regions a CodeCommit key ID saved for every region covers. Absent
|
||||
* from an older server. */
|
||||
codecommitRegions?: string[];
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
/** The suggested email while none is set: `<username>@smartgift.vn`. */
|
||||
@@ -5895,6 +6227,38 @@ export interface CloneProgressView {
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/** A background clone (`GET /api/lanes/clone/jobs`), kept by the server
|
||||
* across reloads and restarts; `interrupted` = the server restarted during it. */
|
||||
export interface CloneJob {
|
||||
id: string;
|
||||
kind: "workspace" | "source";
|
||||
state: "queued" | "running" | "done" | "failed" | "interrupted" | "cancelled";
|
||||
url: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
target: string;
|
||||
/** The workspace a `source` clone is added to. */
|
||||
laneId: number | null;
|
||||
/** 1-based place in the clone queue while `queued`. */
|
||||
position: number | null;
|
||||
phase: CloneProgressView["phase"];
|
||||
label: string | null;
|
||||
percent: number | null;
|
||||
detail: string | null;
|
||||
error: { code: string; message: string; auth?: { protocol: string; host: string } } | null;
|
||||
result: { laneId: number | null; path: string; created?: boolean } | null;
|
||||
createdAt: string;
|
||||
finishedAt: string | null;
|
||||
request: {
|
||||
url: string;
|
||||
parent: string;
|
||||
name: string;
|
||||
tool?: string;
|
||||
title?: string;
|
||||
partial?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LaneSourcesView {
|
||||
instructions: string;
|
||||
/** The workspace's tool can take folders and instructions (claude, codex). */
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @file cloneJobs.ts
|
||||
* @description The app-wide list of background clones (`GET /api/lanes/clone/jobs`),
|
||||
* shown by `components/BackgroundJobsDock.tsx`. The server keeps every job
|
||||
* across reloads and restarts, so this store is only a view of that list:
|
||||
* it is read on start, on window focus, after each start/retry/cancel, and
|
||||
* every 1.5 s while any job is queued or running. A job SEEN going from
|
||||
* active to finished in this page is announced once — a toast, the
|
||||
* `agenthub:clone-job` window event (open pages re-read what the clone
|
||||
* changed) and the starter's own `onDone` — and a done one then leaves the
|
||||
* dock by itself; a job that finished unseen (a closed tab, F5) stays until
|
||||
* dismissed. An older server answers a start synchronously: that answer is
|
||||
* announced the same way as a job that is already done.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import i18n from "i18next";
|
||||
import { api, type CloneJob, type LaneSourcesView } from "./api";
|
||||
import type { Lane } from "./types";
|
||||
import { toast } from "./toast";
|
||||
|
||||
/** Window event carrying a finished `CloneJob` in `detail`. */
|
||||
export const CLONE_JOB_EVENT = "agenthub:clone-job";
|
||||
|
||||
const POLL_MS = 1500;
|
||||
/** How long a done job the user watched stays in the dock after its toast. */
|
||||
const DONE_LINGER_MS = 8000;
|
||||
|
||||
export const isActiveJob = (j: Pick<CloneJob, "state">) =>
|
||||
j.state === "queued" || j.state === "running";
|
||||
|
||||
type OnDone = (job: CloneJob, lane?: Lane) => void;
|
||||
type Creds = { username: string; password: string; remember?: boolean };
|
||||
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
|
||||
type SourceBody = Omit<Parameters<typeof api.lanes.cloneFolder>[1], "background" | "progressToken">;
|
||||
|
||||
let jobs: CloneJob[] = [];
|
||||
/** The last state seen per job, to tell a transition from a repeat. */
|
||||
const known = new Map<string, CloneJob["state"]>();
|
||||
/** Jobs seen queued/running in this page: only their finish is announced. */
|
||||
const watched = new Set<string>();
|
||||
/** Dismissed here: a poll already in flight must not bring them back. */
|
||||
const hidden = new Set<string>();
|
||||
const callbacks = new Map<string, OnDone>();
|
||||
const listeners = new Set<() => void>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let started = false;
|
||||
let inflight: Promise<void> | null = null;
|
||||
let legacyId = 0;
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
function publish(next: CloneJob[]) {
|
||||
jobs = next.filter((j) => !hidden.has(j.id));
|
||||
emit();
|
||||
}
|
||||
|
||||
function upsert(job: CloneJob) {
|
||||
publish([job, ...jobs.filter((j) => j.id !== job.id)]);
|
||||
}
|
||||
|
||||
/** The user-facing reason a job did not finish. */
|
||||
export function cloneJobError(job: CloneJob): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
const e = job.error;
|
||||
if (job.state === "cancelled") return t("lanes:cloneJobs.cancelled");
|
||||
if (job.state === "interrupted" || e?.code === "EINTERRUPTED")
|
||||
return t("lanes:cloneJobs.interrupted");
|
||||
const host = e?.auth?.host || job.url;
|
||||
if (e?.code === "ECREDENTIALS") return t("lanes:clone.loginNeeded", { host });
|
||||
if (e?.code === "ESSHAUTH") return t("lanes:clone.sshRefused", { host });
|
||||
return e?.message || t("lanes:cloneJobs.failed");
|
||||
}
|
||||
|
||||
function announce(job: CloneJob, lane?: Lane) {
|
||||
const t = i18n.t.bind(i18n);
|
||||
const path = job.result?.path || job.target;
|
||||
// A failure or a stop is told by its chip alone (reason + Retry): a toast
|
||||
// repeated the same long message on top of the chip, covering its buttons.
|
||||
if (job.state === "done") {
|
||||
toast.success(
|
||||
job.kind === "workspace"
|
||||
? t("lanes:cloneJobs.doneWorkspace", {
|
||||
name: job.name,
|
||||
path,
|
||||
workspace: lane?.title || job.request?.title || job.name,
|
||||
})
|
||||
: t("lanes:cloneJobs.doneSource", { name: job.name, path })
|
||||
);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<CloneJob>(CLONE_JOB_EVENT, { detail: job }));
|
||||
if (job.state === "done") {
|
||||
const cb = callbacks.get(job.id);
|
||||
callbacks.delete(job.id);
|
||||
try {
|
||||
cb?.(job, lane);
|
||||
} catch {
|
||||
/* a page's follow-up never breaks the store */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function observe(list: CloneJob[]) {
|
||||
const finishedHere: CloneJob[] = [];
|
||||
for (const j of list) {
|
||||
const prev = known.get(j.id);
|
||||
known.set(j.id, j.state);
|
||||
if (isActiveJob(j)) {
|
||||
watched.add(j.id);
|
||||
continue;
|
||||
}
|
||||
if (!watched.has(j.id) || prev === j.state) continue;
|
||||
watched.delete(j.id);
|
||||
finishedHere.push(j);
|
||||
}
|
||||
for (const j of finishedHere) {
|
||||
announce(j);
|
||||
if (j.state === "done") setTimeout(() => void dismissCloneJob(j.id), DONE_LINGER_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(ms = POLL_MS) {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void refreshCloneJobs();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/** Read the list now (shared while one read is in flight). */
|
||||
export function refreshCloneJobs(): Promise<void> {
|
||||
if (!inflight) {
|
||||
inflight = Promise.resolve()
|
||||
.then(() => api.lanes.cloneJobs())
|
||||
.then((r) => {
|
||||
const list = Array.isArray(r?.jobs) ? r.jobs : [];
|
||||
observe(list);
|
||||
publish(list);
|
||||
})
|
||||
// An older server (404) or a blip: keep what we had; a start polls again.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
if (jobs.some(isActiveJob)) schedule();
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const onFocus = () => void refreshCloneJobs();
|
||||
|
||||
/** Start following the list (the dock calls this once, on mount). */
|
||||
export function ensureCloneJobs(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void refreshCloneJobs();
|
||||
window.addEventListener("focus", onFocus);
|
||||
}
|
||||
|
||||
function track(job: CloneJob, onDone?: OnDone) {
|
||||
if (onDone) callbacks.set(job.id, onDone);
|
||||
if (isActiveJob(job)) watched.add(job.id);
|
||||
known.set(job.id, job.state);
|
||||
upsert(job);
|
||||
if (isActiveJob(job)) schedule();
|
||||
else observe([job]);
|
||||
}
|
||||
|
||||
/** The folder `git clone <url>` makes: the URL's last segment, no `.git`. */
|
||||
const cloneName = (url: string) =>
|
||||
url
|
||||
.replace(/\/+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop()
|
||||
?.replace(/\.git$/, "") || url;
|
||||
|
||||
/** An older server's synchronous answer, as a job that is already done. */
|
||||
function legacyJob(
|
||||
kind: CloneJob["kind"],
|
||||
body: { url: string; name?: string; parent?: string; title?: string },
|
||||
path: string | undefined,
|
||||
laneId: number | null
|
||||
): CloneJob {
|
||||
path = path || "";
|
||||
const name = body.name || path.split("/").pop() || cloneName(body.url);
|
||||
return {
|
||||
id: `legacy-${++legacyId}`,
|
||||
kind,
|
||||
state: "done",
|
||||
url: body.url,
|
||||
name,
|
||||
parent: body.parent ?? "",
|
||||
target: path,
|
||||
laneId: kind === "source" ? laneId : null,
|
||||
position: null,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId, path },
|
||||
createdAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
request: { url: body.url, parent: body.parent ?? "", name, title: body.title },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a clone in the background. Resolves with its job once the server
|
||||
* accepted it (a 4xx validation error rejects, for the form to show);
|
||||
* `onDone` runs when this page sees it finish.
|
||||
*/
|
||||
export async function startClone(
|
||||
spec:
|
||||
| { kind: "workspace"; body: WorkspaceBody }
|
||||
| { kind: "source"; laneId: number; body: SourceBody },
|
||||
opts: { onDone?: OnDone } = {}
|
||||
): Promise<CloneJob> {
|
||||
if (spec.kind === "workspace") {
|
||||
const r = await api.lanes.clone({ ...spec.body, background: true });
|
||||
if ("job" in r && r.job) {
|
||||
track(r.job, opts.onDone);
|
||||
return r.job;
|
||||
}
|
||||
const legacy = r as { lane: Lane; path: string };
|
||||
const job = legacyJob("workspace", spec.body, legacy.path, legacy.lane?.id ?? null);
|
||||
callbacks.set(job.id, opts.onDone ?? (() => {}));
|
||||
announce(job, legacy.lane);
|
||||
return job;
|
||||
}
|
||||
const r = await api.lanes.cloneFolder(spec.laneId, { ...spec.body, background: true });
|
||||
if ("job" in r && r.job) {
|
||||
track(r.job, opts.onDone);
|
||||
return r.job;
|
||||
}
|
||||
const legacy = r as LaneSourcesView & { path: string };
|
||||
const job = legacyJob("source", spec.body, legacy.path, spec.laneId);
|
||||
callbacks.set(job.id, opts.onDone ?? (() => {}));
|
||||
announce(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Retry a failed / interrupted / cancelled job, optionally with a login. */
|
||||
export async function retryCloneJob(id: string, credentials?: Creds): Promise<CloneJob> {
|
||||
const { job } = await api.lanes.retryCloneJob(id, credentials);
|
||||
const cb = callbacks.get(id);
|
||||
callbacks.delete(id);
|
||||
hidden.add(id);
|
||||
publish(jobs.filter((j) => j.id !== id));
|
||||
track(job, cb);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Stop a queued or running clone. */
|
||||
export async function cancelCloneJob(id: string): Promise<void> {
|
||||
try {
|
||||
const { job } = await api.lanes.cancelCloneJob(id);
|
||||
if (job) {
|
||||
observe([job]);
|
||||
upsert(job);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; code?: string };
|
||||
if (err.code === "EDONE") {
|
||||
void refreshCloneJobs();
|
||||
return;
|
||||
}
|
||||
if (err.status === 404) {
|
||||
toast.error(i18n.t("lanes:cloneJobs.stopUnsupported"));
|
||||
return;
|
||||
}
|
||||
toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a finished job here and on the server. */
|
||||
export async function dismissCloneJob(id: string): Promise<void> {
|
||||
hidden.add(id);
|
||||
callbacks.delete(id);
|
||||
watched.delete(id);
|
||||
publish(jobs.filter((j) => j.id !== id));
|
||||
await Promise.resolve()
|
||||
.then(() => api.lanes.dismissCloneJob(id))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
/** The jobs to show, newest first. */
|
||||
export function useCloneJobs(): CloneJob[] {
|
||||
return useSyncExternalStore(subscribe, () => jobs);
|
||||
}
|
||||
|
||||
/** Tests only: forget everything, as a fresh page load would. */
|
||||
export function resetCloneJobsForTests(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
jobs = [];
|
||||
known.clear();
|
||||
watched.clear();
|
||||
hidden.clear();
|
||||
callbacks.clear();
|
||||
inflight = null;
|
||||
started = false;
|
||||
window.removeEventListener("focus", onFocus);
|
||||
emit();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file gitChanged.ts
|
||||
* @description One window event, `agenthub:git-changed` `{laneId, action}`,
|
||||
* fired after EVERY git write the dashboard sends for a workspace
|
||||
* (`api.lanes.gitAction` fires it, success or failure — a failed shelve or
|
||||
* a conflicted restore still moved files). Every view that shows that
|
||||
* workspace's git state — the Changes panel of each repo, the header's facts
|
||||
* (`useLaneGitFacts`, the rail counts included), the workspace home's branch —
|
||||
* listens and reads again, so a switch made in one of them is never left
|
||||
* stale in another for a poll interval. The views do not share a callback;
|
||||
* this is the bus between them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export const GIT_CHANGED_EVENT = "agenthub:git-changed";
|
||||
|
||||
export interface GitChangedDetail {
|
||||
laneId: number;
|
||||
/** The git action that ran (`switch`, `commit`, `shelve`, …). */
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** Tell every view of `laneId` that its git state may have moved. */
|
||||
export function emitGitChanged(laneId: number, action: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<GitChangedDetail>(GIT_CHANGED_EVENT, { detail: { laneId, action } })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `fn` after every git write on workspace `laneId`. The latest `fn` is
|
||||
* used, so a caller may pass an inline closure without resubscribing.
|
||||
*/
|
||||
export function useGitChanged(laneId: number, fn: (action: string) => void): void {
|
||||
const latest = useRef(fn);
|
||||
latest.current = fn;
|
||||
useEffect(() => {
|
||||
const on = (e: Event) => {
|
||||
const d = (e as CustomEvent<GitChangedDetail>).detail;
|
||||
if (d && d.laneId === laneId) latest.current(d.action);
|
||||
};
|
||||
window.addEventListener(GIT_CHANGED_EVENT, on);
|
||||
return () => window.removeEventListener(GIT_CHANGED_EVENT, on);
|
||||
}, [laneId]);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file Types of the Changes panel's history reads and multi-commit
|
||||
* cherry-pick/revert (server/lib/git-history.js, server/lib/git-pick.js),
|
||||
* plus the small pure helpers the history components share: parsing a search
|
||||
* box into the log's filters and naming a badge's kind.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { LaneChangeFile } from "./types";
|
||||
|
||||
/** A ref pointing at a commit, typed for its badge. */
|
||||
export interface RefBadge {
|
||||
kind: "local" | "remote" | "tag" | "head";
|
||||
name: string;
|
||||
/** The branch HEAD is on. */
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
/** `GET /git/commit` — one commit in full. */
|
||||
export interface CommitDetails {
|
||||
available: true;
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
author: string;
|
||||
email: string;
|
||||
date: string;
|
||||
committer: string;
|
||||
committerEmail: string;
|
||||
committedAt: string;
|
||||
parents: string[];
|
||||
badges: RefBadge[];
|
||||
isHead: boolean;
|
||||
branches: string[];
|
||||
tags: string[];
|
||||
containsTruncated: boolean;
|
||||
}
|
||||
|
||||
/** `GET /git/patch`. */
|
||||
export interface CommitPatch {
|
||||
available: true;
|
||||
sha: string;
|
||||
patch: string;
|
||||
omitted: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** One version in `GET /git/file-history`. */
|
||||
export interface FileVersion {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
/** A, M, R, D, C… */
|
||||
status: string;
|
||||
/** The file's name in THIS commit. */
|
||||
path: string;
|
||||
/** A rename's old name. */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export interface FileHistoryPage {
|
||||
available: true;
|
||||
path: string;
|
||||
commits: FileVersion[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** `GET /git/blame-at`. */
|
||||
export interface BlameAt {
|
||||
available: true;
|
||||
path: string;
|
||||
rev: string;
|
||||
commits: Record<
|
||||
string,
|
||||
{ short: string; author?: string; date?: string; summary?: string; previous?: string }
|
||||
>;
|
||||
lines: { sha: string | null; text: string }[];
|
||||
}
|
||||
|
||||
export interface TreeAtEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
dir: boolean;
|
||||
submodule: boolean;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
/** `GET /git/tree-at`. */
|
||||
export interface TreeAt {
|
||||
available: true;
|
||||
rev: string;
|
||||
path: string;
|
||||
entries: TreeAtEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface CompareCommit {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
/** `GET /git/compare` without `path`. */
|
||||
export interface CompareResult {
|
||||
available: true;
|
||||
a: string;
|
||||
b: string;
|
||||
mode: "three" | "two";
|
||||
onlyA: CompareCommit[];
|
||||
onlyB: CompareCommit[];
|
||||
commitsTruncated: boolean;
|
||||
files: (LaneChangeFile & { from?: string })[];
|
||||
filesTruncated: boolean;
|
||||
}
|
||||
|
||||
/** `GET /git/compare` with `path`. */
|
||||
export interface CompareFile {
|
||||
available: true;
|
||||
file: LaneChangeFile & { secret?: boolean };
|
||||
}
|
||||
|
||||
export interface PlannedCommit {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** `GET /git/pick-plan`. */
|
||||
export interface PickPlan {
|
||||
available: true;
|
||||
op: "cherry-pick" | "revert";
|
||||
commits: PlannedCommit[];
|
||||
branch: string | null;
|
||||
clean: boolean;
|
||||
repoRoot: boolean;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
/** `GET /git/sequence`. */
|
||||
export type PickSequence =
|
||||
| { available: true; active: false }
|
||||
| {
|
||||
available: true;
|
||||
active: true;
|
||||
operation: "cherry-pick" | "revert";
|
||||
current: { sha: string; short: string; subject: string } | null;
|
||||
remaining: { sha: string; short: string; subject: string }[];
|
||||
done: number;
|
||||
startedFrom: string | null;
|
||||
conflicts: string[];
|
||||
};
|
||||
|
||||
/** The answer of a multi-commit pick/revert and of the banner's buttons. */
|
||||
export interface PickResult {
|
||||
action: string;
|
||||
ok: true;
|
||||
output: string;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
commits?: { sha: string; short: string; subject: string }[];
|
||||
conflicts: string[];
|
||||
paused: boolean;
|
||||
empty?: boolean;
|
||||
applied?: PlannedCommit[];
|
||||
}
|
||||
|
||||
export type PickAction = "pick-continue" | "pick-skip" | "pick-abort" | "undo-pick";
|
||||
|
||||
/** The history's search, as the log's query parameters. */
|
||||
export interface HistoryFilter {
|
||||
q?: string;
|
||||
author?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One search box into the log's filters, like GitLens' search: `author:x`
|
||||
* and `path:x` (or `file:x`) are their own filters, the rest is message text
|
||||
* (a hex word also finds that SHA prefix, server-side).
|
||||
*/
|
||||
export function parseSearch(text: string): HistoryFilter {
|
||||
const out: HistoryFilter = {};
|
||||
const rest: string[] = [];
|
||||
for (const m of text.matchAll(/(\w+):("[^"]*"|\S+)|("[^"]*"|\S+)/g)) {
|
||||
if (m[1]) {
|
||||
const v = m[2]!.replace(/^"|"$/g, "");
|
||||
const key = m[1].toLowerCase();
|
||||
if (key === "author" || key === "by") out.author = v;
|
||||
else if (key === "path" || key === "file") out.path = v;
|
||||
else rest.push(m[0]);
|
||||
} else rest.push(m[3]!.replace(/^"|"$/g, ""));
|
||||
}
|
||||
const q = rest.join(" ").trim();
|
||||
if (q) out.q = q.slice(0, 200);
|
||||
if (out.author) out.author = out.author.slice(0, 200);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Selected commits, in the order they appear in the list (newest first). */
|
||||
export function inListOrder<T extends { sha: string }>(list: T[], picked: Set<string>): T[] {
|
||||
return list.filter((c) => picked.has(c.sha));
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @file Client calls for the better-conflicts and history-rewrite surface
|
||||
* (server/lib/git-conflicts.js, server/lib/git-rewrite.js): the merge editor's
|
||||
* stage read and save, the reflog, remotes, the rewrite plans, and the rewrite
|
||||
* / remote writes — each a POST /git/<action> like `api.lanes.gitAction`. Kept
|
||||
* out of api.ts so its own surface stays in one file. Every write fires the
|
||||
* shared `agenthub:git-changed` event once (`emitGitChanged`, gitChanged.ts),
|
||||
* as `api.lanes.gitAction` does, so every open git view reads again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { gitUrl, request, type GitTarget } from "./api";
|
||||
import { emitGitChanged } from "./gitChanged";
|
||||
|
||||
/** One conflicted file's three sides and the working file it saves over. */
|
||||
export interface ConflictSides {
|
||||
available: boolean;
|
||||
path: string;
|
||||
operation: "merge" | "cherry-pick" | "revert" | "rebase" | null;
|
||||
branch: string | null;
|
||||
incoming: { sha: string; short: string; subject: string } | null;
|
||||
base: string | null;
|
||||
ours: string | null;
|
||||
theirs: string | null;
|
||||
working: string | null;
|
||||
sha: string | null;
|
||||
usable: boolean;
|
||||
binary: boolean;
|
||||
tooBig: boolean;
|
||||
}
|
||||
|
||||
export interface ReflogEntry {
|
||||
index: number;
|
||||
selector: string;
|
||||
sha: string;
|
||||
short: string;
|
||||
action: string;
|
||||
message: string;
|
||||
time: number | null;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface GitRemote {
|
||||
name: string;
|
||||
url: string;
|
||||
pushUrl: string;
|
||||
defaultBranch: string | null;
|
||||
/** Its fetched remote-tracking branches (every one, a local copy or not). */
|
||||
branches?: string[];
|
||||
}
|
||||
|
||||
export interface PlanCommit {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
pushed: boolean;
|
||||
}
|
||||
|
||||
interface PlanCommon {
|
||||
available: boolean;
|
||||
head: string;
|
||||
branch: string | null;
|
||||
subdir: boolean;
|
||||
operation: string | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
export interface RebasePlan extends PlanCommon {
|
||||
kind: "rebase";
|
||||
onto: string;
|
||||
commits: PlanCommit[];
|
||||
behind: number;
|
||||
pushed: number;
|
||||
}
|
||||
export interface InteractivePlan extends PlanCommon {
|
||||
kind: "interactive";
|
||||
base: string;
|
||||
merges: boolean;
|
||||
commits: PlanCommit[];
|
||||
pushed: number;
|
||||
}
|
||||
export interface ResetPlan extends PlanCommon {
|
||||
kind: "reset";
|
||||
target: string;
|
||||
related: boolean;
|
||||
dropped: number;
|
||||
added: number;
|
||||
pushed: number;
|
||||
}
|
||||
export interface PushPlan extends PlanCommon {
|
||||
kind: "push";
|
||||
remote: string;
|
||||
remoteBranch: string;
|
||||
remoteSha: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
protected: boolean;
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export type TodoAction = "pick" | "reword" | "squash" | "fixup" | "drop";
|
||||
export interface TodoEntry {
|
||||
sha: string;
|
||||
action: TodoAction;
|
||||
message?: string;
|
||||
}
|
||||
export type ResetMode = "soft" | "mixed" | "keep" | "hard";
|
||||
|
||||
export type RewriteAction =
|
||||
| "rebase"
|
||||
| "rebase-continue"
|
||||
| "rebase-skip"
|
||||
| "rebase-abort"
|
||||
| "rebase-interactive"
|
||||
| "reset"
|
||||
| "undo-rewrite"
|
||||
| "force-push"
|
||||
| "remote-branch-delete"
|
||||
| "remote-add"
|
||||
| "remote-rename"
|
||||
| "remote-remove"
|
||||
| "conflict-save";
|
||||
|
||||
export interface RewriteResult {
|
||||
action: string;
|
||||
ok: true;
|
||||
output: string;
|
||||
before?: string;
|
||||
after?: string | null;
|
||||
conflicts?: string[];
|
||||
stopped?: boolean;
|
||||
needsForcePush?: boolean;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export const gitRewrite = {
|
||||
conflict: (t: GitTarget, path: string) =>
|
||||
request<ConflictSides>(gitUrl(t, "/conflict", new URLSearchParams({ path }))),
|
||||
reflog: (t: GitTarget, opts: { ref?: string; skip?: number; limit?: number } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.ref) q.set("ref", opts.ref);
|
||||
if (opts.skip) q.set("skip", String(opts.skip));
|
||||
if (opts.limit) q.set("limit", String(opts.limit));
|
||||
return request<{ available: boolean; ref?: string; entries: ReflogEntry[]; more: boolean }>(
|
||||
gitUrl(t, "/reflog", q)
|
||||
);
|
||||
},
|
||||
remotes: (t: GitTarget) =>
|
||||
request<{ available: boolean; remotes: GitRemote[] }>(gitUrl(t, "/remotes")),
|
||||
planRebase: (t: GitTarget, onto: string) =>
|
||||
request<RebasePlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "rebase", onto }))),
|
||||
planInteractive: (t: GitTarget, base?: string) =>
|
||||
request<InteractivePlan>(
|
||||
gitUrl(
|
||||
t,
|
||||
"/rewrite-plan",
|
||||
new URLSearchParams(base ? { kind: "interactive", base } : { kind: "interactive" })
|
||||
)
|
||||
),
|
||||
planReset: (t: GitTarget, target: string) =>
|
||||
request<ResetPlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "reset", target }))),
|
||||
planPush: (t: GitTarget) =>
|
||||
request<PushPlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "push" }))),
|
||||
/** POST /git/<action>; fires `agenthub:git-changed` once after it answers (ok or not). */
|
||||
run: async (t: GitTarget, action: RewriteAction, body: Record<string, unknown> = {}) => {
|
||||
try {
|
||||
return await request<RewriteResult>(gitUrl(t, `/${action}`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
emitGitChanged(typeof t === "number" ? t : t.lane, action);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The error message of a failed call, for an inline alert. */
|
||||
export function errText(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/** The error code of a failed call (`ApiError.code` / `.status` shape). */
|
||||
export function errCode(e: unknown): string | undefined {
|
||||
const c = (e as { code?: unknown })?.code;
|
||||
return typeof c === "string" ? c : undefined;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file openInPreview.ts — the ONE rule every link in the dashboard follows
|
||||
* (a chat's markdown link or bare URL, a terminal's link): a page served by
|
||||
* THIS machine (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) opens in the
|
||||
* session view's Preview tab, everything else in a browser tab as before.
|
||||
* The request travels as a window event (like `openInChanges.ts`); the
|
||||
* Workspace page answers it only while it shows a session panel, so with no
|
||||
* listener — another page, or a remote dashboard where "localhost" is the
|
||||
* viewer's own machine — the link falls back to a browser tab. Also holds the
|
||||
* URL checks the Preview tab itself uses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const OPEN_IN_PREVIEW_EVENT = "agenthub:open-in-preview";
|
||||
|
||||
/** The event's detail. `handled` is set by the listener, read by the sender. */
|
||||
export type OpenInPreview = { url: string; handled: boolean };
|
||||
|
||||
const LOOPBACK = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
|
||||
|
||||
/** A URL the Preview may show: http(s) only, `0.0.0.0` read as `localhost`
|
||||
* (a dev server prints it, a browser cannot open it), never the dashboard's
|
||||
* own origin (the frame would then BE the dashboard, cookies and API
|
||||
* included). A bare `localhost:3000` gets `http://`. Null when refused. */
|
||||
export function normalizePreviewUrl(raw: string, self = window.location.origin): string | null {
|
||||
let text = raw.trim();
|
||||
if (!text) return null;
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `http://${text}`;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
if (u.username || u.password) return null;
|
||||
if (u.hostname === "0.0.0.0") u.hostname = "localhost";
|
||||
if (u.origin === self) return null;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/** True for a page served by the machine the BROWSER runs on. */
|
||||
export function isLoopbackUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.has(u.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The dashboard itself is opened from this machine: only then does a
|
||||
* `localhost` link name the same machine the agents run on. */
|
||||
export function dashboardIsLocal(hostname = window.location.hostname): boolean {
|
||||
return LOOPBACK.has(hostname);
|
||||
}
|
||||
|
||||
/** Asks the open session panel to show `url`. False when nothing took it
|
||||
* (not a local page, a remote dashboard, no session panel). */
|
||||
export function openInPreview(url: string): boolean {
|
||||
if (!isLoopbackUrl(url) || !dashboardIsLocal()) return false;
|
||||
const target = normalizePreviewUrl(url);
|
||||
if (!target) return false;
|
||||
const detail: OpenInPreview = { url: target, handled: false };
|
||||
window.dispatchEvent(new CustomEvent(OPEN_IN_PREVIEW_EVENT, { detail }));
|
||||
return detail.handled;
|
||||
}
|
||||
|
||||
/** A link click: Preview when the rule says so, else a browser tab. A
|
||||
* Ctrl/Cmd/Shift/middle click always keeps the browser's own behaviour. */
|
||||
export function onLinkClick(
|
||||
e: {
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
button: number;
|
||||
preventDefault: () => void;
|
||||
},
|
||||
url: string
|
||||
): void {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
||||
if (openInPreview(url)) e.preventDefault();
|
||||
}
|
||||
@@ -368,10 +368,15 @@ function typeRow(options: PaneMenuOption[]): number {
|
||||
/** The status line a tool's TUI shows only while a turn runs, read off its
|
||||
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
||||
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
|
||||
* tool missing here (omp, Cursor) has no line we know. */
|
||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it.
|
||||
* Claude ≥ 2.1.3xx keeps `esc to interrupt` only in its footer, which a
|
||||
* narrow pane, a tip or a notice replaces, so its spinner line counts too:
|
||||
* a verb, an ellipsis and the turn's time in parentheses
|
||||
* (`✶ Skedaddling… (41s · ↓ 2.8k tokens)`) — the line a finished turn leaves
|
||||
* (`✻ Cooked for 1m 2s`) has neither. A tool missing here (omp, Cursor) has
|
||||
* no line we know. */
|
||||
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
||||
claude: /\besc to interrupt\b/i,
|
||||
claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i,
|
||||
codex: /\besc to interrupt\b/i,
|
||||
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
||||
};
|
||||
|
||||
@@ -2731,6 +2731,8 @@ export interface Lane {
|
||||
stage_seconds: number | null;
|
||||
last_event_seconds: number | null;
|
||||
liveness: "active" | "idle" | "dead";
|
||||
/** The workspace's folder no longer exists (deleted from Files or a shell). */
|
||||
folder_missing?: boolean;
|
||||
/** Stage id the server's heuristic infers from ingested tool events, or null
|
||||
* if no signal was seen. Independent of `stage` (the agent's own declaration). */
|
||||
detected_stage: string | null;
|
||||
@@ -2890,7 +2892,7 @@ export type LaneGitFacts =
|
||||
* an older server. */
|
||||
merging?: boolean;
|
||||
/** Which one `merging` is about; absent on an older server (= merge). */
|
||||
operation?: "merge" | "cherry-pick" | "revert" | null;
|
||||
operation?: "merge" | "cherry-pick" | "revert" | "rebase" | null;
|
||||
/** Files the merge still holds unmerged, repo-wide. */
|
||||
conflicts?: number;
|
||||
/** The branch "vs base" compares against: the lane's own base, else the
|
||||
@@ -2935,6 +2937,10 @@ export interface LaneCommit {
|
||||
refs: string[];
|
||||
/** Parent shas, first parent first — the history's commit graph. */
|
||||
parents: string[];
|
||||
/** Author e-mail (the history graph's tooltip). */
|
||||
email?: string;
|
||||
/** The refs pointing here, typed for the graph's badges (`gitHistory.ts`). */
|
||||
badges?: import("./gitHistory").RefBadge[];
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file uploadJobs.ts
|
||||
* @description The app-wide list of File Explorer uploads, shown by
|
||||
* `components/BackgroundJobsDock.tsx` so an upload's progress outlives the
|
||||
* Files page. The upload loop itself stays in `FileExplorer.tsx`; it reports
|
||||
* here (`beginUpload` → `update` → `end`) and checks the job's AbortSignal,
|
||||
* which **Stop** (`stopUpload`) fires. The bytes live in the browser, so an
|
||||
* upload cannot survive a reload: while one runs, a small record of it is
|
||||
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
||||
* a record that stopped being refreshed as an interrupted upload until it is
|
||||
* dismissed. A fresh record belongs to another open tab and is left alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export interface UploadJob {
|
||||
id: string;
|
||||
/** Where the files go, as the user reads it ("workspace/folder"). */
|
||||
folder: string;
|
||||
/** The file (or batch) being sent now. */
|
||||
name: string;
|
||||
done: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
bytes: number;
|
||||
state: "running" | "stopping" | "interrupted";
|
||||
}
|
||||
|
||||
type Stored = UploadJob & { beat: number };
|
||||
|
||||
const KEY = "agenthub.uploads.inflight";
|
||||
const BEAT_MS = 2000;
|
||||
/** A running record older than this is from a page that is gone. */
|
||||
const STALE_MS = 6000;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
const controllers = new Map<string, AbortController>();
|
||||
/** Ids this page owns in storage (its own uploads and the interrupted ones it shows). */
|
||||
const mine = new Set<string>();
|
||||
let uploads: UploadJob[] = [];
|
||||
let beat: ReturnType<typeof setInterval> | undefined;
|
||||
let lastWrite = 0;
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
function readStored(): Stored[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(KEY) || "[]");
|
||||
return Array.isArray(v) ? (v as Stored[]).filter((r) => r && typeof r.id === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
lastWrite = Date.now();
|
||||
try {
|
||||
const others = readStored().filter((r) => !mine.has(r.id));
|
||||
const own = uploads.map((u) => ({ ...u, beat: lastWrite }));
|
||||
const all = [...others, ...own];
|
||||
if (all.length) localStorage.setItem(KEY, JSON.stringify(all));
|
||||
else localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* storage off: the dock still works for this page */
|
||||
}
|
||||
}
|
||||
|
||||
/** Load what a previous page left behind: a stale running record = interrupted. */
|
||||
function loadInterrupted() {
|
||||
const now = Date.now();
|
||||
const left = readStored().filter(
|
||||
(r) => r.state === "interrupted" || now - (Number(r.beat) || 0) > STALE_MS
|
||||
);
|
||||
for (const r of left) mine.add(r.id);
|
||||
uploads = left.map((r) => ({
|
||||
id: r.id,
|
||||
folder: String(r.folder ?? ""),
|
||||
name: String(r.name ?? ""),
|
||||
done: Number(r.done) || 0,
|
||||
total: Number(r.total) || 0,
|
||||
sent: Number(r.sent) || 0,
|
||||
bytes: Number(r.bytes) || 0,
|
||||
state: "interrupted",
|
||||
}));
|
||||
if (left.length) persist();
|
||||
}
|
||||
loadInterrupted();
|
||||
|
||||
function set(id: string, patch: Partial<UploadJob>) {
|
||||
uploads = uploads.map((u) => (u.id === id ? { ...u, ...patch } : u));
|
||||
emit();
|
||||
if (Date.now() - lastWrite > 1000) persist();
|
||||
}
|
||||
|
||||
function heartbeat() {
|
||||
const running = uploads.some((u) => u.state !== "interrupted");
|
||||
if (running && !beat) beat = setInterval(persist, BEAT_MS);
|
||||
if (!running && beat) {
|
||||
clearInterval(beat);
|
||||
beat = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Register an upload; the loop reports through the handle and checks `signal`. */
|
||||
export function beginUpload(init: { folder: string; total: number; bytes: number }) {
|
||||
const id = `up-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const ctrl = new AbortController();
|
||||
controllers.set(id, ctrl);
|
||||
mine.add(id);
|
||||
uploads = [...uploads, { id, name: "", done: 0, sent: 0, state: "running", ...init }];
|
||||
emit();
|
||||
persist();
|
||||
heartbeat();
|
||||
return {
|
||||
id,
|
||||
signal: ctrl.signal,
|
||||
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent">>) => set(id, patch),
|
||||
end: () => {
|
||||
controllers.delete(id);
|
||||
uploads = uploads.filter((u) => u.id !== id);
|
||||
emit();
|
||||
persist();
|
||||
mine.delete(id);
|
||||
heartbeat();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop a running upload: the request in flight is dropped, the loop ends. */
|
||||
export function stopUpload(id: string): void {
|
||||
const ctrl = controllers.get(id);
|
||||
if (!ctrl) return;
|
||||
set(id, { state: "stopping" });
|
||||
ctrl.abort();
|
||||
}
|
||||
|
||||
/** Forget an interrupted upload's record. */
|
||||
export function dismissUpload(id: string): void {
|
||||
uploads = uploads.filter((u) => u.id !== id);
|
||||
emit();
|
||||
persist();
|
||||
mine.delete(id);
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
export function useUploadJobs(): UploadJob[] {
|
||||
return useSyncExternalStore(subscribe, () => uploads);
|
||||
}
|
||||
|
||||
/** Tests only: re-read storage as a fresh page load would. */
|
||||
export function reloadUploadsForTests(): void {
|
||||
for (const c of controllers.values()) c.abort();
|
||||
controllers.clear();
|
||||
mine.clear();
|
||||
if (beat) clearInterval(beat);
|
||||
beat = undefined;
|
||||
lastWrite = 0;
|
||||
loadInterrupted();
|
||||
emit();
|
||||
}
|
||||
@@ -32,7 +32,10 @@
|
||||
* takes its folder on the server — or a plugin's copy — on a click, else
|
||||
* asks for the folder and uploads it onto the same row); an installed copy
|
||||
* that differs from the newest version shows **Update** (a pack: "Update N"),
|
||||
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
|
||||
* which replaces it after a confirmation, the old copy moved aside. A skill or
|
||||
* file row says when its CONTENT last changed ("Updated 3h ago", `versionAt`),
|
||||
* and the install count's list marks each installer's copy Latest / Old
|
||||
* version / Removed. Editing a skill
|
||||
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
||||
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
|
||||
* row of your own only takes it out of your library — it stays in the Hub
|
||||
@@ -81,6 +84,7 @@ import { toast } from "../lib/toast";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import {
|
||||
FileUploadPanel,
|
||||
@@ -1271,6 +1275,7 @@ function Meta({
|
||||
server: boolean;
|
||||
date?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (server && a.owner && !a.mine)
|
||||
parts.push(
|
||||
@@ -1278,7 +1283,18 @@ function Meta({
|
||||
{a.owner}
|
||||
</span>
|
||||
);
|
||||
if (date)
|
||||
// A skill or file is a snapshot that changes: when it last did. Anything else: when added.
|
||||
if (a.kind === "skill" || a.kind === "file")
|
||||
parts.push(
|
||||
<span
|
||||
key="at"
|
||||
className="shrink-0"
|
||||
title={t("versionAtFull", { date: new Date(a.versionAt).toLocaleString() })}
|
||||
>
|
||||
{t("versionAt", { ago: timeAgo(a.versionAt) })}
|
||||
</span>
|
||||
);
|
||||
else if (date)
|
||||
parts.push(
|
||||
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
||||
{fmtDate(a.createdAt)}
|
||||
@@ -1536,7 +1552,10 @@ function DetailDialog({
|
||||
: []),
|
||||
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
|
||||
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
|
||||
{ label: t("stat.updated"), value: fmtDate(a.updatedAt) },
|
||||
{
|
||||
label: t("stat.updated"),
|
||||
value: new Date(a.versionAt).toLocaleString(),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Modal title={a.title} onClose={onClose} size="md">
|
||||
@@ -1707,6 +1726,13 @@ function DetailDialog({
|
||||
* The list floats over the page (a portal at the button, below it or above
|
||||
* when there is no room, kept inside the window): inside the card list's
|
||||
* `overflow-hidden` it was cut off at the last card. */
|
||||
/** An installer's copy against the newest version: green, amber, grey. */
|
||||
const STATE_CLASS = {
|
||||
latest: "bg-status-success/15 text-status-success",
|
||||
outdated: "bg-status-warning/15 text-status-warning",
|
||||
removed: "bg-surface-2 text-fg-muted",
|
||||
} as const;
|
||||
|
||||
function InstallsButton({ item }: { item: Artifact }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -1798,6 +1824,14 @@ function InstallsButton({ item }: { item: Artifact }) {
|
||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||
)}
|
||||
</span>
|
||||
{i.state && i.state !== "unknown" && (
|
||||
<span
|
||||
data-testid="install-state"
|
||||
className={`shrink-0 rounded px-1 text-[10px] ${STATE_CLASS[i.state]}`}
|
||||
>
|
||||
{t(`installState.${i.state}`)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||
{new Date(i.at).toLocaleString()}
|
||||
</span>
|
||||
|
||||
@@ -50,7 +50,10 @@ export function FilesPage() {
|
||||
// The workspace last shown first: Files reached from a workspace opens
|
||||
// THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty
|
||||
// but for hidden files); the home is the fallback.
|
||||
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
|
||||
// A workspace whose folder was deleted has nothing to open.
|
||||
const lanes = (await api.lanes.list().catch(() => ({ lanes: [] }))).lanes.filter(
|
||||
(l) => !l.folder_missing
|
||||
);
|
||||
const last = lastWorkspace();
|
||||
const shown = lanes.find((l) => l.id === last);
|
||||
if (shown) return setStart({ lane: shown.id });
|
||||
|
||||
@@ -141,6 +141,8 @@ import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { FileExplorer } from "../components/lanes/FileExplorer";
|
||||
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||
import { OPEN_IN_PREVIEW_EVENT, type OpenInPreview } from "../lib/openInPreview";
|
||||
import { UrlPreview } from "../components/lanes/UrlPreview";
|
||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
@@ -148,6 +150,8 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
|
||||
/** localStorage key for the Changes panel's open/closed state. */
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** The session panel's tab — Changes or Preview. A viewer preference. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the last width the user dragged the panel to — what
|
||||
@@ -799,6 +803,25 @@ export function Workspace() {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// The panel's two tabs: Changes (git + files) and Preview (a web page).
|
||||
// Both stay mounted once shown (CSS), so an unsaved edit or a loaded page
|
||||
// survives a switch.
|
||||
const [panelTab, setPanelTab] = useState<"changes" | "preview">(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(PANEL_TAB_KEY) === "preview" ? "preview" : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
const [previewShown, setPreviewShown] = useState(panelTab === "preview");
|
||||
useEffect(() => {
|
||||
if (panelTab === "preview") setPreviewShown(true);
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
||||
@@ -1050,6 +1073,22 @@ export function Workspace() {
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
}, []);
|
||||
// A link to a local page asks for it (`openInPreview`): taken only while a
|
||||
// session panel is on screen, so elsewhere the link opens a browser tab.
|
||||
const [previewRequest, setPreviewRequest] = useState<{ url: string; nonce: number } | null>(null);
|
||||
const previewHostRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<OpenInPreview>).detail;
|
||||
if (!previewHostRef.current) return;
|
||||
detail.handled = true;
|
||||
setChangesOpen(true);
|
||||
setPanelTab("preview");
|
||||
setPreviewRequest((r) => ({ url: detail.url, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
window.addEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||
}, []);
|
||||
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
@@ -1230,6 +1269,7 @@ export function Workspace() {
|
||||
// The session view (console or replay) fills the page edge to edge, like an
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
previewHostRef.current = sessionView;
|
||||
const viewportLocked =
|
||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
|
||||
@@ -1567,9 +1607,25 @@ export function Workspace() {
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
<div role="tablist" className="flex items-center gap-0.5">
|
||||
{(["changes", "preview"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-testid={`panel-tab-${tab}`}
|
||||
aria-selected={panelTab === tab}
|
||||
onClick={() => setPanelTab(tab)}
|
||||
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
panelTab === tab
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{tLanes(tab === "changes" ? "panel.changes" : "panel.preview")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1652,7 +1708,22 @@ export function Workspace() {
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
{previewShown && (
|
||||
<div
|
||||
className={panelTab === "preview" ? "flex min-h-0 flex-1 flex-col" : "hidden"}
|
||||
>
|
||||
<UrlPreview
|
||||
laneId={currentLane.id}
|
||||
ports={currentLane.ports ?? {}}
|
||||
request={previewRequest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
panelTab === "changes" ? "flex min-h-0 flex-1 flex-col px-3 pt-2" : "hidden"
|
||||
}
|
||||
>
|
||||
{panelAt &&
|
||||
[
|
||||
...(showRoot
|
||||
|
||||
@@ -77,6 +77,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
|
||||
installs: 0,
|
||||
createdAt: "2026-10-14T00:00:00Z",
|
||||
updatedAt: "2026-10-14T00:00:00Z",
|
||||
versionAt: "2026-10-14T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -349,9 +350,16 @@ describe("Artifacts", () => {
|
||||
mine: true,
|
||||
at: "2026-10-19T10:00:00Z",
|
||||
replaced: false,
|
||||
state: "latest",
|
||||
paths: ["/h/.claude/skills/deploy"],
|
||||
},
|
||||
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
||||
{
|
||||
user: "khoa",
|
||||
mine: false,
|
||||
at: "2026-10-18T10:00:00Z",
|
||||
replaced: true,
|
||||
state: "outdated",
|
||||
},
|
||||
],
|
||||
unrecorded: 1,
|
||||
});
|
||||
@@ -363,6 +371,12 @@ describe("Artifacts", () => {
|
||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||
// Each install says whether its copy is the newest version.
|
||||
expect(
|
||||
within(box)
|
||||
.getAllByTestId("install-state")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Latest", "Old version"]);
|
||||
// Over the page, outside the card list that clips its overflow — and a
|
||||
// click inside it keeps it open.
|
||||
expect(box.parentElement).toBe(document.body);
|
||||
@@ -436,6 +450,24 @@ describe("Artifacts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("says when a skill's content last changed, not when it was renamed", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "deploy",
|
||||
source: "deploy",
|
||||
updatedAt: new Date().toISOString(),
|
||||
versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
|
||||
@@ -1053,7 +1053,28 @@ describe("split panes", () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-right-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: "auto",
|
||||
prompt: "",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("a new AI pane starts in the account's Session defaults (bypass included)", async () => {
|
||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||
lanesToReturn = lanesToReturn.map((l) => ({ ...l, tool: "claude" }));
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-right-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: "auto",
|
||||
prompt: "",
|
||||
permissionMode: "bypassPermissions",
|
||||
});
|
||||
localStorage.removeItem("agenthub.permissionMode");
|
||||
});
|
||||
|
||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.45 (rebuild client to match)
|
||||
UI build v2.8.58 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+352
-25
@@ -398,10 +398,17 @@ Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
|
||||
into the folder it has open — of the user's own home or of a workspace's own
|
||||
folder, never a session worktree, an extra folder or another user's home —
|
||||
through `POST /api/lanes/clone` below with that folder as `parent`, so every
|
||||
check of that route applies, and opens a workspace on the clone. It sends a
|
||||
`progressToken` and shows the queue place / git progress while the clone runs;
|
||||
closing the dialog then only hides it behind a progress chip (click to reopen)
|
||||
— the request keeps going and its outcome arrives as a toast.
|
||||
check of that route applies, and opens a workspace on the clone. It sends
|
||||
`background: true` and closes as soon as the server accepted the clone; from
|
||||
then on the app-wide dock (`client/src/components/BackgroundJobsDock.tsx`,
|
||||
rendered once by `Layout`, store `client/src/lib/cloneJobs.ts` polling
|
||||
`GET /api/lanes/clone/jobs`) shows the queue place, git's progress, **Stop**
|
||||
(click twice) and the outcome — on every page, after F5 and after a server
|
||||
restart (an `interrupted` job offers **Retry**, a login failure an inline login
|
||||
form, a refused SSH key a link to Settings → Git). Add workspace → **Clone**
|
||||
and a workspace's **Or clone repositories** box start their clones the same
|
||||
way. File Explorer uploads show in the same dock (`client/src/lib/uploadJobs.ts`)
|
||||
with **Stop**; an upload cut short by a reload is shown as interrupted.
|
||||
|
||||
#### Clone a repository into a new workspace
|
||||
|
||||
@@ -431,7 +438,8 @@ else `400 EBADFIELD`) is the browser's own name for the clone: while the POST is
|
||||
held open, `GET /api/lanes/clone/progress/:token` answers its 1-based
|
||||
`position` among the waiting clones, then git's `--progress` line parsed into
|
||||
`phase`, `percent` and `detail` (`"450/1000, 120.50 MiB | 5.20 MiB/s"`) — the
|
||||
form polls it every second (`CloneProgress.tsx`). Only the user who started
|
||||
dashboard's own forms no longer use it (they start background jobs, whose
|
||||
list carries the same fields, rendered by `CloneProgress.tsx`). Only the user who started
|
||||
the clone sees it (404 otherwise), and it is dropped a minute after the clone
|
||||
ends. `url` passes the same allowlist as the git bar's
|
||||
`publish` (`https://` with no userinfo, `ssh://`, scp-like `user@host:path` —
|
||||
@@ -455,6 +463,57 @@ own config (`core.sshCommand` naming the key, or a member's
|
||||
`credential.helper=store --file=…`), so later fetch/pull/push — the dashboard's
|
||||
and an agent's — use it too; the user's global git config is never touched.
|
||||
|
||||
#### Background clone jobs
|
||||
|
||||
```
|
||||
POST /api/lanes/clone Body: { …as above, background: true } → 202 { job }
|
||||
POST /api/lanes/:id/sources/clone Body: { …, background: true } → 202 { job }
|
||||
GET /api/lanes/clone/jobs → 200 { jobs: CloneJob[] }
|
||||
DELETE /api/lanes/clone/jobs/:id → 200 { ok: true } (404 ENOTFOUND, 409 EBUSY while queued/running)
|
||||
POST /api/lanes/clone/jobs/:id/cancel → 200 { job } (404 ENOTFOUND, 409 EDONE once finished)
|
||||
POST /api/lanes/clone/jobs/:id/retry Body: { credentials? } → 202 { job } (404 ENOTFOUND, 409 EBUSY unless failed/interrupted/cancelled)
|
||||
|
||||
CloneJob = { id, kind: "workspace"|"source", state: "queued"|"running"|"done"|"failed"|"interrupted"|"cancelled",
|
||||
url, name, parent, target, laneId (source: its workspace), position (1-based while queued),
|
||||
phase, label, percent, detail, error: { code, message, auth? } | null,
|
||||
result: { laneId, path, created? } | null, createdAt, finishedAt,
|
||||
request: { url, parent, name, tool?, title?, partial? }, pid }
|
||||
```
|
||||
|
||||
`background: true` (`400 EBADFIELD` when not a boolean) runs every check of
|
||||
the clone POST synchronously — a refusal still answers 4xx at once — then
|
||||
answers `202 { job }` and clones in the background
|
||||
(`server/lib/clone-jobs.js`); without it both routes hold the request open as
|
||||
above. git runs in a **detached runner** (`scripts/clone-runner.js`: its own
|
||||
session and process group, the exact command `worktree.git()` would run —
|
||||
`sudo -n -u ah-<name> git …` for a member — with stderr appended to
|
||||
`<data dir>/runtime/clone-jobs/<id>/stderr.log` and `exit.json` written when
|
||||
git ends), so a clone survives a browser reload AND a server restart or
|
||||
update (the unit is `KillMode=process`; a git whose stderr was a pipe into the
|
||||
server died of SIGPIPE when it restarted). The server tails the log for
|
||||
`phase`/`percent`/`detail`, and when the runner ends it creates the workspace
|
||||
(or adds the extra folder) and broadcasts it exactly as the held-open route,
|
||||
once; a failure records that route's own `error.code`/`message`
|
||||
(`ECREDENTIALS` with `auth`, `ESSHAUTH`, `ENOREPO`, `EEXISTS`, `ECLONE`…). Jobs
|
||||
live in `<data dir>/runtime/clone-jobs.json` (0600, tmp + rename; progress at
|
||||
most every 2 s, every state change at once). On start the server **adopts** a
|
||||
job whose runner is still alive (counted against `AGENTHUB_CLONE_CONCURRENCY`,
|
||||
tailed, finished when it exits), finishes one whose `exit.json` is already
|
||||
there, requeues a queued one, and marks the rest `interrupted`
|
||||
(`EINTERRUPTED`, "AgentHub restarted while this clone ran") — a queued job
|
||||
that carried a typed login is interrupted too, so the login is typed again.
|
||||
`cancel` takes a queued job out of the queue or SIGTERMs the runner's process
|
||||
group (git removes its half-made folder; sudo relays the signal to a member's
|
||||
git); the job ends `cancelled` (`ECANCELLED`) and never makes its workspace or
|
||||
extra folder. `retry` starts a NEW job from the old one's `request` through
|
||||
every check of a fresh POST, then drops the old one. `request` never holds a
|
||||
credential: a typed `credentials` login is used for that one run (the host's
|
||||
travels in the runner's environment, as it would in git's) — a host
|
||||
`remember` is saved only when the same server process sees the clone succeed.
|
||||
Done jobs are kept 24 h, failed/interrupted/cancelled ones 7 days. A job is
|
||||
only ever looked up among the caller's own (`scope.ownerFor`; local mode:
|
||||
every job), 404 otherwise. The browser polls; there is no websocket message.
|
||||
|
||||
#### Update a workspace
|
||||
|
||||
```http
|
||||
@@ -827,10 +886,67 @@ that file.
|
||||
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
||||
— `400 ENOBASE` when neither exists — and `400 ENOFILECHANGE` for a `path` with no changes in that range.
|
||||
|
||||
#### History: commit details, patch, file history, blame, browse, compare
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/commit?sha=<rev>
|
||||
GET /api/lanes/:id/git/patch?sha=<rev>
|
||||
GET /api/lanes/:id/git/file-history?path=<path>[&skip=<n>][&limit=<n>]
|
||||
GET /api/lanes/:id/git/blame-at?path=<path>[&rev=work|<rev>]
|
||||
GET /api/lanes/:id/git/tree-at?rev=<rev>[&path=<folder>]
|
||||
GET /api/lanes/:id/git/compare?a=<rev>&b=<rev>[&mode=three|two][&path=<file>[&from=<old name>]]
|
||||
GET /api/lanes/:id/git/pick-plan?op=cherry-pick|revert&shas=<sha>,<sha>
|
||||
GET /api/lanes/:id/git/sequence
|
||||
```
|
||||
|
||||
The History tab's reads (`server/lib/git-history.js`, mounted by
|
||||
`server/routes/lane-git-history.js`). Read-only and unguarded like every
|
||||
`/git` read, each resolving its repo exactly like them (the lane row, or
|
||||
`?session=` / `?source=` / `?repo=`); `{available:false}` with HTTP **200**
|
||||
for a folder git cannot read. A revision is a sha or a ref name (no leading
|
||||
`-`, no `..`; `~n`/`^n` suffixes allowed) and is resolved to a commit before
|
||||
git sees it, else `400 EBADREV`. A path is lane-relative with no `..`
|
||||
(`400 EBADPATH`). `403 ESECRET` / `EFOREIGN` as for `/git/file`.
|
||||
|
||||
- **`/git/commit`** → `{sha, short, subject, message, author, email, date,
|
||||
committer, committerEmail, committedAt, parents, badges, isHead, branches,
|
||||
tags, containsTruncated}` — the details pane; `branches`/`tags` contain the
|
||||
commit (50 each). Its files are `/git/changes?range=commit&sha=`.
|
||||
- **`/git/patch`** → `{sha, patch, omitted, truncated}` — `git format-patch -1
|
||||
--stdout --relative` over the commit's files inside the workspace (Copy
|
||||
patch). A secret file's diff is NEVER in it: it is named in `omitted`.
|
||||
2 MB cap. A merge commit is `400 EMERGECOMMIT`.
|
||||
- **`/git/file-history`** → `{path, commits:[{sha, short, subject, author,
|
||||
date, parents, status, path, from?}], hasMore}` — `git log --follow`: each
|
||||
commit with the name the file had THERE (`from` = a rename's old name), so
|
||||
a version's diff is `/git/changes?range=commit&sha=<sha>&path=<its path>`.
|
||||
- **`/git/blame-at`** → `{path, rev, commits: {<sha>: {short, author, date,
|
||||
summary, previous?}}, lines: [{sha|null, text}]}` — blame at a commit (or
|
||||
`work`), with each line's text; `previous` is the commit to blame next
|
||||
("before this change"). 1 MB cap (`400 ETOOBIG`), `400 ENOFILE`.
|
||||
- **`/git/tree-at`** → `{rev, path, entries:[{path, name, dir, submodule,
|
||||
size}], truncated}` — one folder at a commit (Browse files), folders first,
|
||||
5000 entries; a secret file is left out of the list, and its content stays
|
||||
refused by `/git/file?rev=`.
|
||||
- **`/git/compare`** → `{a, b, mode, onlyA, onlyB, commitsTruncated, files,
|
||||
filesTruncated}` — the commits only in `a` / only in `b`
|
||||
(`--left-right a...b`, 300 each) and the files that differ: `mode=three`
|
||||
(default) is `a...b`, what `b` adds since they diverged; `mode=two` is
|
||||
`a b`, the trees as they stand. With `path` (and a rename's `from`):
|
||||
`{a, b, mode, file}` with that file's hunks (200 KB cap); a secret file is
|
||||
`{secret: true, hunks: []}`.
|
||||
- **`/git/pick-plan`** → `{op, commits, branch, clean, repoRoot, busy}` — what
|
||||
`cherry-pick`/`revert` with `shas` would run, in the order it would run it
|
||||
(a pick oldest first, a revert newest first; ancestry, then date), for the
|
||||
confirmation dialog. `400 EBADSHA` / `EMERGECOMMIT`.
|
||||
- **`/git/sequence`** → `{active:false}` or `{active:true, operation, current,
|
||||
remaining, done, startedFrom, conflicts}` — the cherry-pick/revert that is
|
||||
paused (the banner's "paused at X (2 of 5)"); a merge is not reported here.
|
||||
|
||||
#### Read a workspace's commits, a file, or the file tree
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>][&ref=<branch>]
|
||||
GET /api/lanes/:id/git/log?limit=30[&skip=<n>][&all=1][&ref=<branch>][&q=<text>][&author=<name>][&path=<path>]
|
||||
GET /api/lanes/:id/git/blame?path=<path>
|
||||
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
|
||||
PUT /api/lanes/:id/git/file {path, content, baseSha}
|
||||
@@ -848,15 +964,25 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read —
|
||||
except the file explorer's `/git/tree`, `/git/tree-all` and `/git/ls`, which
|
||||
read the disk instead for a folder that is not a git repo (below).
|
||||
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
|
||||
refs, parents}]}`, newest first in `--date-order` (no parent before its
|
||||
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, email,
|
||||
date, refs, parents, badges}], hasMore, filtered}`, newest first in
|
||||
`--date-order` (no parent before its children; `parents` = parent shas,
|
||||
first parent first) — the History tab's commit graph. `badges` are the refs
|
||||
pointing at the commit, typed for drawing: `{kind: "local"|"remote"|"tag"|"head",
|
||||
name, current?}` (`current` = the branch HEAD is on, `head` = a detached
|
||||
HEAD; `refs/remotes/*/HEAD` left out). `limit` is clamped to 500 (default
|
||||
30) and `skip` pages it; `hasMore` says another page exists. `all=1` reads
|
||||
every branch, remote branch and tag (`--exclude=refs/stash --all`). Search:
|
||||
`q=` matches the message case-insensitively and LITERALLY (never a regex),
|
||||
and a hex `q` also puts the commit with that SHA prefix first; `author=`
|
||||
matches name or e-mail, literally; `path=` (alias `file=`) narrows it to the
|
||||
commits touching that path — relative, no `..` segment, else
|
||||
`400 EBADPATH` — with each `parents` REWRITTEN to the file's own previous
|
||||
commit (`--parents`), so the file's graph is one joined-up line. `ref=` reads another branch's history instead of HEAD's
|
||||
(where the panel's Cherry-pick finds its commits): a local or
|
||||
remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
remote-tracking branch that exists, else `400 ENOBRANCH`. `q`/`author` over
|
||||
200 characters are `400 EBADQUERY`; a walk is killed after 20 s. Reading
|
||||
one commit's diff is `/git/changes?range=commit&sha=`.
|
||||
- **`/git/blame`** → `{available, path, commits: {<sha>: {short, author,
|
||||
date, summary}}, lines: [<sha>|null]}` — `git blame --porcelain` of the
|
||||
working-tree file (Annotate); `null` = a line not committed yet. Same
|
||||
@@ -957,6 +1083,25 @@ comes from the workspace row, never from the query (that is what `GET
|
||||
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
|
||||
only a repo inside their own folder, else `403 EFOLDER`).
|
||||
|
||||
#### Read every branch and tag (the Branches panel)
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/refs
|
||||
```
|
||||
|
||||
`{"available": true, "current": "main", "head": "<sha>", "local": [...], "remotes": [{"remote": "origin", "branches": [...]}], "tags": [...], "truncated": false}`
|
||||
— what the git bar's **Branches & tags** panel (`client/src/components/lanes/git/BranchManager.tsx`)
|
||||
lists, read by `server/lib/git-refs.js`. Each `local` entry carries `name`,
|
||||
`sha`, `current`, `upstream` (or null), `ahead`/`behind` against it (null
|
||||
without one; `gone: true` when the upstream ref is gone), the last commit's
|
||||
`subject` and `date`, `merged` (into HEAD — what `git branch -d` checks),
|
||||
`worktree` (another worktree holding it) and `protected`. `remotes` groups the
|
||||
fetched remote-tracking branches by remote (symbolic `HEAD`s left out). Each
|
||||
tag carries `name`, its target commit `sha`, `annotated`, and for an
|
||||
annotated tag its `message`, `tagger` and `date`. At most 1000 of each kind
|
||||
(`truncated`). Same target rules as every git read (`?session=`, `?source=`,
|
||||
`?repo=`); a folder that is not a repo answers `available: false` with 200.
|
||||
|
||||
#### A session's own worktree (`?session=`)
|
||||
|
||||
Every `/api/lanes/:id/git…` route above and below takes `?session=<id>`: the
|
||||
@@ -1045,6 +1190,44 @@ each call spends the user's Claude quota. Answers `{"message": "..."}`;
|
||||
for a path git does not report changed, `400 ENOTOOL` without `claude` on PATH,
|
||||
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
|
||||
|
||||
#### Conflicts, reflog, remotes and rewrite plans
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/conflict?path=<path>
|
||||
GET /api/lanes/:id/git/reflog?ref=HEAD|<branch>&skip=0&limit=50
|
||||
GET /api/lanes/:id/git/remotes
|
||||
GET /api/lanes/:id/git/rewrite-plan?kind=rebase&onto=<branch>
|
||||
GET /api/lanes/:id/git/rewrite-plan?kind=interactive[&base=<sha>]
|
||||
GET /api/lanes/:id/git/rewrite-plan?kind=reset&target=<sha>
|
||||
GET /api/lanes/:id/git/rewrite-plan?kind=push
|
||||
```
|
||||
|
||||
Reads (no same-origin guard), all taking `?session=`/`?source=`/`?repo=` like
|
||||
the other git routes (`server/lib/git-conflicts.js`, `server/lib/git-rewrite.js`).
|
||||
|
||||
- **`/git/conflict`** — the 3-way merge editor's input for a path
|
||||
`git ls-files -u` lists as unmerged right now (else `400 EBADPATH`), with
|
||||
`/git/file`'s guards (`403 ESECRET`, 1 MB cap): `{operation, branch,
|
||||
incoming: {sha, short, subject}|null, base, ours, theirs, working, sha,
|
||||
usable, binary, tooBig}`. Each side is read by its blob id (stages 1/2/3); a
|
||||
side git does not have is `null`. `sha` is the `baseSha` `conflict-save`
|
||||
needs. In a rebase `ours` is the branch being rebased onto and `theirs` the
|
||||
commit being replayed.
|
||||
- **`/git/reflog`** — `git log -g` of HEAD or of a local branch that exists
|
||||
(`400 EBADBRANCH`/`ENOBRANCH`), newest first: `{ref, more, entries:
|
||||
[{index, selector, sha, short, action, message, time, subject}]}`.
|
||||
- **`/git/remotes`** — `{remotes: [{name, url, pushUrl, defaultBranch,
|
||||
branches}]}`; an http(s) URL loses its whole userinfo and any other keeps only
|
||||
the user name, so no password or token is ever answered.
|
||||
- **`/git/rewrite-plan`** — what a rewrite would do, for the dialog that asks
|
||||
before it: every answer has `head` (the sha a write must name), `branch`,
|
||||
`subdir`, `operation`, `dirty`; `rebase` adds `commits[]` (each with
|
||||
`pushed`), `behind`, `pushed`; `interactive` adds `base`, `merges`,
|
||||
`commits[]` (with full `message`), `pushed`; `reset` adds `target`,
|
||||
`related`, `dropped`, `added`, `pushed`; `push` adds `remote`,
|
||||
`remoteBranch`, `remoteSha` (the lease), `ahead`, `behind`, `protected`,
|
||||
`allowed`. A bad `kind` is `400 EBADMODE`.
|
||||
|
||||
#### Act on a workspace's working copy
|
||||
|
||||
```http
|
||||
@@ -1067,9 +1250,14 @@ cannot collide on `index.lock`.
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones. A dirty tree needs `changes`: `carry` (a plain `git switch` that brings the changes along) or `shelve` (shelve them, then switch) | `{"branch": "...", "create": false, "changes": "carry\|shelve", "note": "..."}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — or, with `switch: true`, `git switch -c <branch> [<base>]`, the uncommitted changes coming along. With `from`, the start point is LOOKED UP (a local or remote branch — a remote one becomes the upstream —, a tag, or a hex sha verified as a commit) and `switch: true` then runs `git switch <branch>` on a clean tree only | `{"branch": "...", "base": "...", "switch": true}` / `{"branch": "...", "from": "v1.2.0", "switch": true}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
| `branch-rename` | `git branch -m <branch> <name>` — the current branch too | `{"branch": "...", "name": "..."}` |
|
||||
| `branch-upstream` | `git branch --set-upstream-to=refs/remotes/<upstream> <branch>`, or `--unset-upstream` with no `upstream` | `{"branch": "...", "upstream": "origin/x"}` / `{"branch": "..."}` |
|
||||
| `tag-create` | `git tag <tag> <sha>`, or `git tag -a -m <message> <tag> <sha>` when a message is given — on HEAD or on a looked-up `from` | `{"tag": "v1.2.0", "message": "...", "from": "..."}` |
|
||||
| `tag-delete` | `git tag -d <tag>` — this machine only | `{"tag": "...", "confirm": true}` |
|
||||
| `tag-push` | `git push <origin\|the only remote> refs/tags/<tag>:refs/tags/<tag>` — ONE tag, never `--tags`, never forced | `{"tag": "..."}` |
|
||||
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
|
||||
| `merge` | `git merge --no-edit -m "Merge branch '<branch>'[ into <current>]" <ref>` — a local or remote-tracking branch that exists; the subject is git's own, never the full refname | `{"branch": "origin/main"}` |
|
||||
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
|
||||
@@ -1077,9 +1265,27 @@ cannot collide on `index.lock`.
|
||||
| `merge-abort` | `git merge --abort` (or `cherry-pick --abort` / `revert --abort`, whichever is open) | `{"confirm": true}` |
|
||||
| `cherry-pick` | `git cherry-pick <sha>` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
|
||||
| `revert` | `git revert --no-edit <sha>` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
|
||||
| `cherry-pick` / `revert` with `shas` | ONE git sequence of 1–50 non-merge commits, in `/git/pick-plan`'s order (`server/lib/git-pick.js`) | `{"shas": ["9b3e74a", "c1d2e3f"]}` |
|
||||
| `pick-continue` | `git cherry-pick --continue` / `git revert --continue` once nothing is unmerged | `{}` |
|
||||
| `pick-skip` | `--skip` the commit the pick/revert stopped at (drops its resolutions) | `{"confirm": true}` |
|
||||
| `pick-abort` | `--abort`: back to where the pick/revert started | `{"confirm": true}` |
|
||||
| `undo-pick` | `git reset --soft <from>` after a pick/revert — its changes stay staged | `{"from": "<sha>", "to": "<sha>", "confirm": true}` |
|
||||
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": "<HEAD the user saw>"}` |
|
||||
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands, or with `paths` exactly those files (`--only`; an empty list rewords and needs a message) | `{"message": "...", "sha": "<HEAD>", "paths": ["..."]}` |
|
||||
| `shelve` | `git stash push --include-untracked -m "agenthub-shelf: <branch> · <ISO time>[ · <note>]" [-- <paths>]` — everything, or exactly the ticked changed `paths` | `{"note": "...", "paths": ["..."]}` |
|
||||
| `unshelve` | `git stash apply --index <sha>` (retried without `--index` when only the index cannot be rebuilt), then `git stash drop` of that entry when it applied cleanly | `{"sha": "<shelf sha>"}` |
|
||||
| `shelf-drop` | `git stash drop` of that entry | `{"sha": "<shelf sha>", "confirm": true}` |
|
||||
| `ignore` | appends one anchored, escaped `/<path>` line per path to the WORKSPACE's own `.gitignore` (created if missing, never a line it already has) — untracked paths only (`400 ETRACKED`), never through a symlink or onto a non-regular file (`400 EBADPATH`) | `{"paths": ["..."]}` |
|
||||
| `conflict-save` | the 3-way merge editor's result written over the conflicted file through `PUT /git/file`'s guards (`baseSha` → `409 ESTALE`, `403 ESECRET`), then `git add`; a result with a marker left is `400 EMARKERS` before anything is written, unless `resolve: false` (save progress, still conflicted) | `{"path": "...", "content": "...", "baseSha": "<sha from /git/conflict>", "resolve": true}` |
|
||||
| `rebase` | `git rebase <ref>` (no editor, no autostash, no other branch moved) — a local or remote-tracking branch that exists | `{"onto": "main", "head": "<HEAD the plan showed>", "force": false}` |
|
||||
| `rebase-continue` / `rebase-skip` | `git rebase --continue` (`409 ECONFLICTS` while anything is unmerged) / `--skip` | — |
|
||||
| `rebase-abort` | `git rebase --abort` | `{"confirm": true}` |
|
||||
| `rebase-interactive` | `git rebase -i <base>` with a todo AgentHub builds from the list — no editor opens | `{"head": "<HEAD>", "base": "<sha, optional>", "todo": [{"sha": "<full sha>", "action": "pick\|reword\|squash\|fixup\|drop", "message": "..."}], "force": false, "confirm": true}` |
|
||||
| `reset` | `git reset --soft\|--mixed\|--keep\|--hard <target>` | `{"target": "<sha>", "mode": "keep", "head": "<HEAD>", "confirm": true, "force": false, "confirmBranch": "<branch, hard only>"}` |
|
||||
| `undo-rewrite` | `git reset --keep <to>` — puts the branch back where a rewrite found it | `{"to": "<before>", "head": "<after>", "confirm": true}` |
|
||||
| `force-push` | `git push -u --force-with-lease=refs/heads/<b>:<expect> <remote> HEAD:refs/heads/<b>` — `<b>` is the current branch's upstream branch | `{"expect": "<remote sha the plan showed, or \"\">", "confirm": true}` |
|
||||
| `remote-branch-delete` | `git push <remote> --delete refs/heads/<branch>` — a fetched remote-tracking branch | `{"branch": "origin/feature", "confirm": true}` |
|
||||
| `remote-add` / `remote-rename` / `remote-remove` | `git remote add -- <name> <url>` / `rename -- <name> <to>` / `remove -- <name>` | `{"name": "upstream", "url": "git@host:o/r.git"}` / `{"name": "...", "to": "..."}` / `{"name": "...", "confirm": true}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
@@ -1097,9 +1303,91 @@ cannot collide on `index.lock`.
|
||||
a merge is finished by `merge-commit` — and any path git does not report
|
||||
changed (`400 EBADPATH`).
|
||||
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
|
||||
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
|
||||
running agent. There is deliberately no rebase, hard reset or stash here —
|
||||
a hard `reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
(`400 EDIRTY`) unless `changes` says what happens to the changes: neither
|
||||
can invent a merge commit or a conflict underneath a running agent. A
|
||||
rebase, a reset or a force push happens only as its own action (below).
|
||||
- **Switch with changes.** `changes: "carry"` runs a plain `git switch`
|
||||
(never `--force`, `--merge` or `--discard-changes`); when git refuses
|
||||
because a carried file would be overwritten the answer is
|
||||
`409 ECARRYCONFLICT` with `files: ["…"]` (git's own list), and nothing has
|
||||
moved. `changes: "shelve"` shelves the changes (as `shelve` below, with an
|
||||
optional `note`) and then switches; if the switch fails the shelf is
|
||||
restored at once and git's error is returned. Every check that needs no
|
||||
switch attempt (`ENOBRANCH`, `EBRANCHBUSY`, …) runs before the shelf is
|
||||
made. Any other `changes` value is `400 EBADCHANGES`. A successful switch
|
||||
answers `shelves: [...]` — the TARGET branch's shelves, which the UI offers
|
||||
back (asking, never applying one itself) — and `shelved: {...}` when it
|
||||
made one. `branch-create` with `switch: true` is `409 EMERGING` while a
|
||||
merge, cherry-pick, revert or rebase is open, and `409 ECARRYCONFLICT` if a
|
||||
`base` other than HEAD clashes with the changes.
|
||||
- **Shelves** are git stash entries AgentHub made, recognised by the message
|
||||
prefix `agenthub-shelf: ` — any other stash is the user's and is never
|
||||
listed or touched. They are listed by `GET /api/lanes/:id/git/shelves`
|
||||
(`{available, shelves: [{ref, sha, branch, note, createdAt, files: [{path,
|
||||
status}]}]}`, newest first; untracked files as `??`) and addressed in
|
||||
writes by `sha`, the stash commit, never by `stash@{n}` (a terminal stash
|
||||
would shift it); an abbreviation works, a sha no shelf has is
|
||||
`409 ENOSHELF`. A stash is repo-wide, so `shelve`, `unshelve` and
|
||||
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
|
||||
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
|
||||
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
|
||||
through the same `git status` check as `discard`. `unshelve` refuses a
|
||||
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
|
||||
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
|
||||
answers `conflicts: [...]`, `kept: true` — the files are resolved with
|
||||
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
|
||||
commit afterwards, the result is an uncommitted change) and the shelf is
|
||||
deleted by hand. `shelf-drop` is irreversible and needs `confirm: true`
|
||||
(`400 ECONFIRM`). Nothing else ever drops a shelf.
|
||||
- **A working agent.** When an agent of the workspace is working (a lane no
|
||||
pane holds: `status: running` from its hooks; a lane a pane holds: one of
|
||||
its own sessions has a main agent `working`), `shelve`, `unshelve` and a
|
||||
`switch` that carries or shelves uncommitted changes answer
|
||||
`409 EAGENTRUNNING` until resent with `confirm: true` — they move files
|
||||
under it.
|
||||
- **History rewrites** (`server/lib/git-rewrite.js`) are allowed because each
|
||||
one's recovery is a button too. Every one runs only from a repo-root
|
||||
workspace (`400 ESUBDIR`), never while a merge, cherry-pick, revert or rebase
|
||||
is open (`409 EMERGING`), and names the full `head` sha the plan showed
|
||||
(`409 ESTALE` once HEAD moved, `400 EBADSHA` without it). Rewriting a commit
|
||||
any remote-tracking branch already holds is `409 EPUSHED` unless the body
|
||||
says `force: true` (the UI shows that a force-push will be needed). Each
|
||||
answers `before`/`after`; `undo-rewrite` (`reset --keep <before>`, only while
|
||||
HEAD is still `after`) undoes it.
|
||||
- `rebase` and `rebase-interactive` need a clean tree (`400 EDIRTY`). A stop
|
||||
is a **200** with `conflicts[]` and `stopped: true`; while a rebase is open
|
||||
`GET /git` reports `merging: true, operation: "rebase"`, every write except
|
||||
`fetch`, `stage`, `unstage`, `discard`, `ignore`, `resolve`,
|
||||
`conflict-save` and the rebase's own steps is `409 EMERGING`, and the
|
||||
banner's `merge-commit` / `merge-abort` act as `rebase-continue` /
|
||||
`rebase-abort` — a rebase started in a terminal included.
|
||||
`rebase-continue`/`-skip`/`-abort` with no rebase open are `409 ENOREBASE`.
|
||||
- `rebase-interactive`: `todo` lists EXACTLY the commits of `base..HEAD`
|
||||
(default base: the parent of the oldest commit no remote has) by full sha,
|
||||
each once, in the new order, else `400 EBADTODO`; a merge commit in the
|
||||
range is `400 EMERGECOMMIT`; a list that changes nothing is
|
||||
`400 ENOCHANGES`; a reword needs a message (`400 EBADMESSAGE`). AgentHub
|
||||
writes the todo and the messages as 0600 files in the repo's own git dir
|
||||
(as the folder's account), hands git `cp` as its sequence editor and `true`
|
||||
as its editor; a squash becomes `fixup` + `exec git commit --amend -F <file>`
|
||||
with the combined (or given) message, so no request text is ever shell.
|
||||
- `reset`: `target` must be an ancestor or descendant of HEAD
|
||||
(`400 ENOTRELATED`), `mode` one of soft/mixed/keep/hard (`400 EBADMODE`);
|
||||
`hard` only on a clean tree (`400 EDIRTY`) and with `confirmBranch` equal to
|
||||
the current branch name (`400 ECONFIRM`). `keep` refuses (git's own `409`)
|
||||
rather than overwrite a local change.
|
||||
- `force-push`: HEAD only, to its upstream branch, with a lease on `expect` —
|
||||
`409 ESTALE` when the remote moved since the fetch the plan read;
|
||||
`main`/`master`/`development`/`develop` are `400 EPROTECTED` unless the
|
||||
server runs with `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`; a detached HEAD is
|
||||
`400 EDETACHED`.
|
||||
- `remote-branch-delete` refuses a protected name (`400 EPROTECTED`) and the
|
||||
remote's default branch (`refs/remotes/<r>/HEAD`, `400 EDEFAULTBRANCH`);
|
||||
a branch not fetched is `400 ENOBRANCH`.
|
||||
- `remote-add` takes its URL through `publish`'s allowlist (`400 EBADURL`)
|
||||
and a name of letters, digits, `.`, `_`, `-` (`400 EBADREMOTE`); a taken
|
||||
name is `400 EREMOTEEXISTS`; `remote-remove` needs `confirm: true`.
|
||||
A stored git credential still goes only to its own host.
|
||||
- **Cherry-pick and revert** follow merge: a commit named by hex sha that
|
||||
exists (`400 EBADSHA`) and is not a merge commit (`400 EMERGECOMMIT`), a
|
||||
clean tree, a repo-root workspace. A conflict is a **200** with
|
||||
@@ -1109,8 +1397,27 @@ cannot collide on `index.lock`.
|
||||
revert whose changes are already there is aborted again and answers
|
||||
`409 EEMPTY`, never left open; one git could not commit (no identity, a
|
||||
failing signer) is undone the same way and answers git's own error. A
|
||||
rebase stopped in a terminal makes every history action, `merge-commit` and
|
||||
`merge-abort` `409 EMERGING` — it is finished where it started.
|
||||
rebase stopped in a terminal makes every history action `409 EMERGING`;
|
||||
`merge-commit`/`merge-abort` then continue/abort that rebase.
|
||||
- **Several commits at once** (`shas` instead of `sha`, `server/lib/git-pick.js`)
|
||||
run as ONE git sequence — `git cherry-pick A B C`, `git revert --no-edit C B A`
|
||||
— in the order `GET /git/pick-plan` shows (a pick oldest first, a revert
|
||||
newest first), under the same guards (1–50 commits, none a merge, a clean
|
||||
repo-root tree, nothing already open — `409 EMERGING`). The answer is
|
||||
`{from, to, commits, conflicts, paused, applied}`: `paused: true` with
|
||||
`conflicts[]` stopped at a conflict, with `empty: true` at a commit whose
|
||||
changes are already here. A paused one shows on `GET /git/sequence` and is
|
||||
finished by `pick-continue` (`409 ECONFLICTS` while anything is unmerged,
|
||||
`409 EEMPTY` when the resolution changed nothing — skip it then),
|
||||
`pick-skip` or `pick-abort` (both `confirm: true`, `400 ECONFIRM`; abort
|
||||
puts the branch back at `from`), `409 ENOMERGE` when nothing is open.
|
||||
`undo-pick {from, to, confirm: true}` takes a finished pick/revert back with
|
||||
`reset --soft <from>` (the changes stay staged): only while HEAD is still
|
||||
`to` (`409 ESTALE`), `from` is its ancestor 1–50 commits back with no merge
|
||||
between (`400 EBADSHA` / `EMERGECOMMIT`), and none of those commits is on a
|
||||
remote-tracking branch (`409 EPUSHED`). The single-`sha` body is unchanged.
|
||||
While a rebase is open every one of these is `409 EMERGING`, and
|
||||
`GET /git/sequence` does not report a rebase as a paused pick.
|
||||
- **Undo commit and amend** rewrite the last commit, so both are
|
||||
`409 EPUSHED` once any remote-tracking branch contains HEAD, `409 ESTALE`
|
||||
when HEAD is no longer the `sha` the request names (`400 EBADSHA` without
|
||||
@@ -1156,11 +1463,31 @@ cannot collide on `index.lock`.
|
||||
`git status` check; an untracked file has no hunks (`400 EUNTRACKED`). A
|
||||
hunk that no longer applies (e.g. part of it is already staged) is
|
||||
`409 EGIT`.
|
||||
- `branch-create` never switches — the usual reason to create one from a workspace
|
||||
- `branch-create` never switches unless `switch: true` — the usual reason to create one from a workspace
|
||||
is to spawn a sibling worktree workspace, and moving this pane's HEAD under a
|
||||
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
|
||||
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
|
||||
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
|
||||
- **Branch & tag tools** (`server/lib/git-refs.js`, the Branches panel): every
|
||||
ref the body names is LOOKED UP — under `refs/heads`, `refs/remotes` or
|
||||
`refs/tags`, or a hex sha `rev-parse --verify <x>^{commit}` confirms — and git
|
||||
is handed the full refname or sha it found, never the request's text
|
||||
(`400 EBADREF`; `400 EAMBIGUOUS` when a branch and a tag share the name —
|
||||
spell out `refs/tags/x`). New names go through `git check-ref-format`
|
||||
(`400 EBADBRANCH` / `400 EBADTAG`; a tag may not start with `-`).
|
||||
`branch-rename`, `branch-upstream` and the three tag actions are repo-wide,
|
||||
so they run only from a repo-root workspace (`400 ESUBDIR`) and never while a
|
||||
merge, cherry-pick, revert or rebase is open (`409 EMERGING`; so is
|
||||
`branch-create` with `switch: true`). `branch-rename` refuses the protected
|
||||
names (`400 EPROTECTED`) and an existing name (`400 EBRANCHEXISTS`);
|
||||
`branch-upstream` takes only a fetched remote branch (`400 ENOBRANCH`) and
|
||||
unsetting one that has none is `400 ENOUPSTREAM`; `tag-create` refuses an
|
||||
existing tag (`400 ETAGEXISTS`) and needs a commit (`400 ENOCOMMITS`);
|
||||
`tag-delete` is irreversible and needs `confirm: true` (`400 ECONFIRM`);
|
||||
`tag-delete`/`tag-push` of a missing tag is `400 ENOTAG`. `tag-push` goes to
|
||||
`origin`, else the only remote (`400 ENOREMOTE`), with the same `EGITAUTH`
|
||||
login flow as `push`; a remote tag already at another commit is a `409 EGIT`
|
||||
in git's words. Deleting a REMOTE tag is not offered.
|
||||
- `publish` is the one write whose **destination comes from the request**, so
|
||||
it only ever gives a remote to a repo that has none: `400 ENOCOMMITS` before
|
||||
the first commit, `400 EREMOTEEXISTS` once any remote exists (it never
|
||||
@@ -1174,7 +1501,7 @@ cannot collide on `index.lock`.
|
||||
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
|
||||
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
|
||||
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`, `EBADURL`, `EREMOTEEXISTS`,
|
||||
`ENOCOMMITS`); git itself exiting
|
||||
`ENOCOMMITS`, `EBADCHANGES`); git itself exiting
|
||||
non-zero (a rejected push, a diverged pull) is
|
||||
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
|
||||
rejection reads better in git's words. Anything else (git not installed, a
|
||||
@@ -1203,7 +1530,7 @@ GET /api/me/git { account: "member"|"host", writable, ssh: {
|
||||
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
|
||||
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. 400 EBADHOST / EBADSSHUSER
|
||||
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/<a CodeCommit host>?allRegions=1&user=<key ID>` removes them all. Otherwise it writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh://<alias>/` rewrite. 400 EBADHOST / EBADSSHUSER
|
||||
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
||||
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
||||
@@ -1737,7 +2064,7 @@ POST /api/locks/:name/release
|
||||
GET /api/lanes/:id/sources
|
||||
PUT /api/lanes/:id/instructions {"instructions": "…" | null}
|
||||
POST /api/lanes/:id/sources/folder {"path": "/abs/dir"}
|
||||
POST /api/lanes/:id/sources/clone {"url": "https://host/o/r.git", "name"?: "r", "credentials"?: {username, password, remember?}}
|
||||
POST /api/lanes/:id/sources/clone {"url": "https://host/o/r.git", "name"?: "r", "credentials"?: {username, password, remember?}, "background"?: true}
|
||||
POST /api/lanes/:id/sources/file raw body, Content-Type: application/octet-stream, X-File-Name: <URI-encoded name>
|
||||
DELETE /api/lanes/:id/sources/:sourceId
|
||||
GET /api/lanes/:id/memory
|
||||
@@ -3147,7 +3474,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
||||
### Artifacts hub
|
||||
|
||||
```http
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`)
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||
@@ -3167,7 +3494,7 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n
|
||||
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
|
||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
||||
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||
@@ -3176,7 +3503,7 @@ POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked and not a web app's own page: never an `index.html`, one under `www`/`public`/`dist`/`build`/`src`/`static`/`templates`/`assets`/`site`/`_site`, or one beside `.js`/`.css`-type files in a folder other than the workspace root — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
||||
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
|
||||
+146
-16
File diff suppressed because one or more lines are too long
+5
-1
@@ -555,6 +555,10 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||
is used only for a pane that runs as its developer (one Linux user per
|
||||
account): AgentHub writes the account's `auth.json` into that developer's
|
||||
`~/.codex` as them (0600) just before the pane starts.
|
||||
- The pane runs `codex --no-daemon` (when that Codex lists the flag in
|
||||
`--help`): Codex ≥ 0.164 otherwise hands a plain session to the home's
|
||||
shared app-server daemon, which outlives every pane and keeps the login
|
||||
it started with — the new `auth.json` would not be used.
|
||||
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
||||
- A developer who signed Codex in themselves keeps their own login.
|
||||
- While a pane of theirs runs on a pool account, their next pane uses the
|
||||
@@ -882,7 +886,7 @@ What runs as the member, and how:
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
|
||||
+881
-13
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.45",
|
||||
"version": "2.8.58",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.45",
|
||||
"version": "2.8.58",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user