chore: v2.8.21 — update progress steps, clone in background, extra SSH keys inline, today's spend

- Plugin update reports its running step (plugin-update-progress.json →
  plugin_update_progress); a hidden update or clone leaves a progress chip.
- Clone here can run in the background.
- Git identity: extra SSH keys are added inline per host (no More keys tab).
- Users: spend today (viewer's local day, ?tz_offset=).
- Prettier on the files touched since v2.8.20.
This commit is contained in:
2026-10-23 10:44:11 +07:00
parent 62b4520507
commit 9dd97a369c
33 changed files with 906 additions and 393 deletions
+62 -2
View File
@@ -19,7 +19,9 @@
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error; "Skip this version" is the one
* failure re-opens the modal with its error. While hidden, a small chip in the
* corner keeps showing the update — with the plugin chain's current step from
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
@@ -184,6 +186,8 @@ export function UpdateNotifier() {
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
/** The running plugin chain's step, from the polls while it runs. */
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
@@ -312,6 +316,7 @@ export function UpdateNotifier() {
api.updates
.status()
.then((s) => {
setProgress(s.plugin_update_progress ?? null);
const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
@@ -342,6 +347,7 @@ export function UpdateNotifier() {
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setProgress(null);
setApplying(true);
try {
const result = await api.updates.apply();
@@ -392,6 +398,53 @@ export function UpdateNotifier() {
}
};
const stepText = progress?.step
? t(`pluginStep.${progress.step}`, {
index: progress.index,
total: progress.total,
defaultValue: progress.step,
})
: null;
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
return (
<button
type="button"
data-testid="update-chip"
onClick={() => setClosedKey(null)}
title={t("chipHint")}
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 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">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
<span className="truncate">
{status?.latest_release
? t("chipTitle", { version: status.latest_release })
: t("updating")}
</span>
</span>
<span className="mt-1 block truncate text-[11px] text-fg-muted">
{stepText || (applying ? t("updating") : t("restartingShort"))}
</span>
{progress && progress.total > 0 ? (
<span
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={progress.index}
aria-valuemin={0}
aria-valuemax={progress.total}
>
<span
className="block h-full bg-accent-solid transition-all"
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
/>
</span>
) : null}
</button>
);
}
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -520,7 +573,14 @@ export function UpdateNotifier() {
{/* The plugin chain pulls a new cache directory and reinstalls
dependencies before it restarts, so it takes minutes, not
seconds — saying "restarting" there would look hung. */}
{pluginManaged ? t("pluginUpdating") : t("restarting")}
<span>
{pluginManaged ? t("pluginUpdating") : t("restarting")}
{stepText ? (
<span className="block mt-0.5 font-medium" data-testid="update-step">
{stepText}
</span>
) : null}
</span>
</div>
) : null}
@@ -312,6 +312,58 @@ describe("UpdateNotifier — closing vs skipping", () => {
});
});
describe("UpdateNotifier — hidden while it runs", () => {
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
statusMock.mockResolvedValue({
...PLUGIN,
plugin_update_progress: {
step: "plugin_update",
index: 2,
total: 3,
started_at: new Date().toISOString(),
},
});
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
).toBeInTheDocument();
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
expect(screen.queryByTestId("update-chip")).toBeNull();
});
});
describe("UpdateNotifier — Auto fix", () => {
it("offers no Not now button, and Auto fix only once an update failed", async () => {
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
+78 -12
View File
@@ -7,6 +7,10 @@
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +22,8 @@ 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";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -26,12 +32,15 @@ export function CloneHereDialog({
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);
@@ -42,10 +51,23 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useDialog(open, panelRef, busy ? () => {} : onClose);
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) return;
if (!open || busyRef.current) return;
if (keepRef.current) {
keepRef.current = false;
return;
}
setUrl("");
setName("");
setLogin(null);
@@ -54,14 +76,39 @@ export function CloneHereDialog({
setError(null);
}, [open, parent]);
if (!open) return null;
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
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 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 {
@@ -69,6 +116,7 @@ export function CloneHereDialog({
url: u,
parent,
name: name.trim() || undefined,
progressToken,
...(login?.username.trim() && login.token
? {
credentials: {
@@ -79,12 +127,16 @@ export function CloneHereDialog({
}
: {}),
});
busyRef.current = false;
setBusy(false);
setToken(null);
onCloned(result.lane, result.path);
} 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") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
setLogin((l) => ({
host,
username: l?.username ?? "",
@@ -93,18 +145,25 @@ export function CloneHereDialog({
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
message = t("clone.sshRefused", { host });
} else {
setError(err instanceof Error ? err.message : String(err));
message = 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);
}
}
};
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={busy ? undefined : onClose}
onClick={onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
@@ -119,7 +178,10 @@ export function CloneHereDialog({
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
<h2
id={titleId}
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
@@ -162,7 +224,10 @@ export function CloneHereDialog({
</p>
</div>
{login && (
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
<div
data-testid="fx-clone-login"
className="space-y-2 rounded-md border border-border p-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
@@ -193,6 +258,7 @@ export function CloneHereDialog({
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
@@ -204,8 +270,8 @@ export function CloneHereDialog({
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
{t("fx.cancel")}
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{busy ? t("clone.background") : t("fx.cancel")}
</button>
<button
type="submit"
@@ -974,6 +974,7 @@ export function FileBrowser({
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 }));
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
lanes: {
list: vi.fn(),
clone: vi.fn(),
cloneProgress: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -553,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
})
);
expect(toast.success).toHaveBeenCalled();
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",
} 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"), "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"
);
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
@@ -14,7 +14,9 @@
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
* each tied to the one key it uses (the SSH part is split into tabs: one per
* kind of host, the extra keys, the other hosts) — and, for
* kind of host, then the other hosts; a named key is a card of its own under
* its type's default one, with the same steps, and "Add another key" makes
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
@@ -24,17 +26,7 @@
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Cloud,
Copy,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
* One SSH key and the steps of the host it is for: no key → a Generate
* button; a key → step 1 (copy its public half) followed by the host's own
* steps. The default key of a type and every named one share it.
*/
function KeyCard({
title,
sub,
type,
label,
k,
id,
writable,
busy,
onGenerate,
onRemove,
@@ -114,12 +108,13 @@ function KeyCard({
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
/** What names the public key field ("RSA", "ED25519" or the key's name). */
label: string;
k: Key | undefined;
id: GitIdentity;
writable: boolean;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
onGenerate: () => Promise<void>;
onRemove: () => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
@@ -130,23 +125,19 @@ function KeyCard({
<div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && id.writable && (
{k && writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
onClick={() => void onRemove()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
@@ -165,9 +156,9 @@ function KeyCard({
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
value={k.publicKey}
rows={type === "rsa" ? 3 : 2}
rows={k.type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
@@ -182,6 +173,104 @@ 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.
*/
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>;
}) {
const { t } = useTranslation("settings");
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
return (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={v.keyId}
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>
</form>
);
}
/**
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
* offers only this key — the default key would log in as the other account.
*/
function GitHostForm({
busy,
onSave,
}: {
busy: boolean;
onSave: (host: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [host, setHost] = useState("github.com");
return (
<form
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(host.trim());
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="github.com"
autoComplete="off"
value={host}
onChange={(e) => setHost(e.target.value)}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
};
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
const makeNamed = async () => {
if (!newKey) return;
const ok = await run(
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
// "Add another key" in a tab makes the next key of that tab's type at once,
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
// account name of the user's own goes in its step 3.
const makeNamed = (type: "ed25519" | "rsa") => {
const prefix = type === "rsa" ? "aws" : "git";
let n = 2;
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
return run(
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
t("gitIdentity.keyMade")
);
};
const addKey = (type: "ed25519" | "rsa") => (
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.addKey")}
</button>
);
const replaceNamed = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
const removeNamed = async (keyId: string) => {
if (
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A login is shown under the key it names; one naming no key (or one gone)
// under the default key.
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) => {
let user = v.keyId.trim();
let host = `git-codecommit.${v.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
// 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 } : {}),
...(aws.key ? { key: aws.key } : {}),
...(k?.id ? { key: k.id } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
const saveGitHost = (host: string, k: Key) => {
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
return run(
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
t("gitIdentity.otherSaved", { host: k.name })
);
};
/** The logins of one key, each with its delete button. */
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
rows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{text(r)}
</span>
{id?.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
));
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
};
/** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
const keySelect = (value: string, onChange: (v: string) => void) => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id && (!only || k.type === only))
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
...(named.length > 0 || id.writable
? [
{
id: "more",
label: named.length
? `${t("gitIdentity.tabMore")} (${named.length})`
: t("gitIdentity.tabMore"),
Icon: KeySquare,
},
]
: []),
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []),
@@ -498,228 +630,116 @@ export function GitIdentitySettings() {
onSelect={setSshTab}
>
{activeSshTab === "git" && (
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
<div className="space-y-2">
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
label="ED25519"
k={keyOf("ed25519")}
writable={id.writable}
busy={busy}
onGenerate={() => generate("ed25519")}
onRemove={() => removeKey("ed25519")}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
{namedOf("ed25519").map((k) => {
const rows = rowsOf(otherRows, k);
return (
<KeyCard
key={k.id}
title={k.name!}
sub={t("gitIdentity.namedGitSub")}
label={k.name!}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => replaceNamed(k)}
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
{id.writable && rows.length === 0 && (
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
)}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/&lt;org&gt;/&lt;repo&gt;.git
</Code>
))}
</Step>
</KeyCard>
);
})}
{id.writable &&
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
addKey("ed25519")}
</div>
)}
{activeSshTab === "aws" && (
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `${r.alias} · ` : ""}
{r.region} · {r.user} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<div className="space-y-2">
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
const isNamed = i > 0 && !!k;
const rows = rowsOf(awsRows, isNamed ? k : undefined);
return (
<KeyCard
key={k?.id ?? "id_rsa"}
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
label={isNamed ? k!.name! : "RSA"}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{loginRows(
rows,
(r) =>
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
)}
{id.writable && (
<AwsLoginForm
busy={busy}
named={isNamed ? k!.name! : undefined}
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
/>
)}
</>
)}
</Step>
{rows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, 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={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={aws.alias}
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
</KeyCard>
);
})}
{id.writable && keys.some((k) => k.type === "rsa") && (
<>
{addKey("rsa")}
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</KeyCard>
)}
{activeSshTab === "more" && (
<section
aria-label={t("gitIdentity.moreKeysTitle")}
className="space-y-2 rounded border border-border p-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.moreKeysTitle")}
</div>
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
</div>
{id.writable && !newKey && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setNewKey({ name: "", type: "rsa" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.newKey")}
</button>
)}
</div>
{newKey && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
onSubmit={(e) => {
e.preventDefault();
void makeNamed();
}}
>
<input
aria-label={t("gitIdentity.keyName")}
placeholder={t("gitIdentity.keyNamePlaceholder")}
autoComplete="off"
autoFocus
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.keyType")}
value={newKey.type}
onChange={(e) =>
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
}
className="select !py-1 !text-xs"
>
<option value="rsa">RSA 4096 (AWS)</option>
<option value="ed25519">ED25519</option>
</select>
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
{t("gitIdentity.generate")}
</button>
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
{t("gitCreds.cancel")}
</button>
</form>
)}
{named.map((k) => (
<div
key={k.id}
className="space-y-1 rounded border border-border-light p-2"
data-testid="git-named-key"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-medium text-fg-primary">
{k.name}
</span>
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{k.type === "rsa" ? "RSA" : "ED25519"}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
onClick={() => void removeNamed(k.id!)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
value={k.publicKey}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
/>
</div>
))}
</section>
</div>
)}
{activeSshTab === "other" && (
@@ -5,9 +5,9 @@
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; a named key
* is made beside the default ones, and a second AWS account is saved under
* its own name with the one key it uses.
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
fingerprint: "SHA256:r",
};
const PROD = {
id: "id_rsa_aws-prod",
id: "id_rsa_aws-2",
type: "rsa" as const,
name: "aws-prod",
name: "aws-2",
publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p",
};
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
// Only an RSA key: the AWS tab opens first.
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
await tab("More keys");
let more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
// One click makes the key — nothing to type before it.
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
const prod = await screen.findByRole("region", { name: "aws-2" });
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
// Another key can be added below it.
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
await tab("AWS CodeCommit");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
// Its own step 3: the account name defaults to 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,
user: "APKA2",
alias: "aws-prod",
key: "id_rsa_aws-prod",
alias: "aws-2",
key: "id_rsa_aws-2",
});
expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).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();
await tab("More keys (1)");
more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
});
it("adds a second GitHub key in its tab and ties it to its host", async () => {
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
const WORK = {
id: "id_ed25519_git-2",
type: "ed25519" as const,
name: "git-2",
publicKey: "ssh-ed25519 W work",
fingerprint: "SHA256:w",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [ED, WORK], sshUsers: [] })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [ED, WORK],
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
})
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
const work = await screen.findByRole("region", { name: "git-2" });
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "github.com",
user: "git",
alias: "git-2",
key: "id_ed25519_git-2",
});
expect(
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
).toBeInTheDocument();
});
});