feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host

- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
This commit is contained in:
2026-10-15 16:42:47 +07:00
parent 758609775f
commit b058179177
24 changed files with 1510 additions and 126 deletions
+177 -2
View File
@@ -102,6 +102,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
let clipboard: Clip | null = null;
/** A menu item's handler that closes its menu first. */
const pick = (m: { close: () => void }, fn: () => void) => () => {
m.close();
fn();
};
/** The workspace id of a place, or null for a home. */
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
@@ -279,6 +284,9 @@ export function FileBrowser({
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const ctx = useContextMenu<FsEntry | null>();
// The sidebar's places and the path bar's crumbs have their own menus.
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
const crumbCtx = useContextMenu<string>();
// A new caller target (another workspace's Files tab, the page's URL) —
// not the echo of our own `go()` through the page's URL, which would wipe
@@ -668,6 +676,18 @@ export function FileBrowser({
const item = trash.find((i) => i.id === id);
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
};
/** Empty one user's whole Trash from the Trash root — asked first. */
const emptyTrashOf = async (home: string, count: number) => {
const ok = await confirmDialog({
title: t("fx.emptyTrashTitle"),
message: t("fx.emptyTrashBody", {
count,
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
}),
confirmLabel: t("fx.emptyTrash"),
});
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
};
const restoreItem = (id: string) => {
const item = trash.find((i) => i.id === id);
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
@@ -772,6 +792,7 @@ export function FileBrowser({
<button
type="button"
onClick={() => go("", { home: "me" })}
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === "me"
)}
@@ -794,6 +815,7 @@ export function FileBrowser({
type="button"
title={t("fx.readOnlyHome")}
onClick={() => go("", { home: h.id })}
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === h.id
)}
@@ -814,6 +836,7 @@ export function FileBrowser({
type="button"
title={l.cwd}
onClick={() => go("", l.id)}
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
className={placeClass(
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)}
@@ -902,6 +925,7 @@ export function FileBrowser({
<button
type="button"
onClick={() => go("")}
onContextMenu={(e) => crumbCtx.open(e, "")}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, "")}
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
@@ -921,6 +945,7 @@ export function FileBrowser({
<button
type="button"
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
@@ -1266,6 +1291,8 @@ export function FileBrowser({
loading={loading}
homeName={homeName}
onOpen={openTrash}
onEmpty={(home, count) => void emptyTrashOf(home, count)}
onRefresh={() => void load()}
/>
) : (
<TrashView
@@ -1274,6 +1301,7 @@ export function FileBrowser({
laneName={laneName}
onRestore={restoreItem}
onPurge={(id) => void purge(id)}
onRefresh={() => void load()}
/>
)}
@@ -1449,6 +1477,78 @@ export function FileBrowser({
</ContextMenu>
)}
{placeCtx.menu && (
<ContextMenu
x={placeCtx.menu.x}
y={placeCtx.menu.y}
onClose={placeCtx.close}
testId="fx-place-menu"
>
{"lane" in placeCtx.menu.item
? (() => {
const l = placeCtx.menu.item.lane;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()
: (() => {
const home = (placeCtx.menu.item as { home: string }).home;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
{t("fx.openTrash")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{crumbCtx.menu && (
<ContextMenu
x={crumbCtx.menu.x}
y={crumbCtx.menu.y}
onClose={crumbCtx.close}
testId="fx-crumb-menu"
>
{(() => {
const dir = crumbCtx.menu.item;
const name = dir.split("/").pop() || placeName(target);
return (
<>
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
<MenuItem
onSelect={pick(crumbCtx, () =>
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
)}
>
{t("fx.downloadZip")}
</MenuItem>
{!ro && clip && (
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
{t("fx.pasteInto")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{ask && (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
@@ -1763,6 +1863,8 @@ function TrashFolders({
loading,
homeName,
onOpen,
onEmpty,
onRefresh,
}: {
homes: FsHome[];
/** Every Trash's items, merged, each tagged with its `home`. */
@@ -1773,13 +1875,20 @@ function TrashFolders({
loading: boolean;
homeName: (id: string) => string;
onOpen: (home: string) => void;
onEmpty: (home: string, count: number) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<string | null>();
const counts = new Map<string, number>();
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1);
const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash-root">
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash-root"
onContextMenu={(e) => menu.open(e, null)}
>
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
{t("fx.trashFoldersHint")}
</div>
@@ -1799,6 +1908,7 @@ function TrashFolders({
type="button"
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
onClick={() => onOpen(h.id)}
onContextMenu={(e) => menu.open(e, h.id)}
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
data-testid="fx-trash-folder"
>
@@ -1833,6 +1943,32 @@ function TrashFolders({
);
})}
</div>
{menu.menu && (
<ContextMenu
x={menu.menu.x}
y={menu.menu.y}
onClose={menu.close}
testId="fx-trash-root-menu"
>
{menu.menu.item !== null ? (
(() => {
const home = menu.menu.item;
const n = counts.get(home) ?? 0;
return (
<>
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
);
})()
) : (
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
)}
</ContextMenu>
)}
</div>
);
}
@@ -1843,16 +1979,23 @@ function TrashView({
laneName,
onRestore,
onPurge,
onRefresh,
}: {
items: FsTrashItem[];
error: string | null;
onRefresh: () => void;
laneName: (id: number) => string;
onRestore: (id: string) => void;
onPurge: (id: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<FsTrashItem | null>();
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash"
onContextMenu={(e) => menu.open(e, null)}
>
{error && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
@@ -1885,6 +2028,7 @@ function TrashView({
key={i.id}
className="border-b border-border/40 hover:bg-surface-2"
data-testid="fx-trash-row"
onContextMenu={(e) => menu.open(e, i)}
>
<td className="truncate px-4 py-1.5">
<span className="flex items-center gap-2" title={i.path}>
@@ -1932,6 +2076,37 @@ function TrashView({
</div>
)}
</div>
{menu.menu && (
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
{menu.menu.item ? (
(() => {
const i = menu.menu.item;
return (
<>
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
{t("fx.restore")}
</MenuItem>
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
{t("fx.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
{t("fx.purge")}
</MenuItem>
</>
);
})()
) : (
<>
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</div>
);
}
@@ -297,6 +297,91 @@ describe("FileExplorer", () => {
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
});
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
vi.mocked(api.files.allTrash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
home: "bob",
owner: "Bob",
},
],
errors: [],
});
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
},
],
});
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
const folders = await screen.findAllByTestId("fx-trash-folder");
// My Trash is empty: its Empty Trash is disabled.
fireEvent.contextMenu(folders[0]!);
expect(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
).toHaveProperty("disabled", true);
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.contextMenu(folders[1]!);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
);
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
);
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
await user.click(
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
);
await waitFor(() =>
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
);
});
it("right-clicks a sidebar workspace and a path crumb", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
);
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
);
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
});
it("shows why a Trash could not be read instead of an empty one", async () => {
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
const user = userEvent.setup();
@@ -10,7 +10,10 @@
* it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
* that same file, so a plain `ssh://git-codecommit…` URL works) — and, for
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
* 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 — 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
@@ -206,9 +209,36 @@ export function GitIdentitySettings() {
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// An account may hold one of each; an older server answers `ssh` only.
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
// The default key of a type (a named key sits beside it).
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
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()),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
const removeNamed = async (keyId: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKeyById(keyId));
};
const generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type);
if (
@@ -260,7 +290,7 @@ 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" });
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`;
@@ -270,23 +300,55 @@ export function GitIdentitySettings() {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
() => api.gitIdentity.setSshUser({ host, user }),
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "" });
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
const [other, setOther] = useState({ host: "", user: "" });
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
if (m?.[1]) host = m[1];
const alias = other.alias.trim().toLowerCase();
const ok = await run(
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
t("gitIdentity.otherSaved", { host })
() =>
api.gitIdentity.setSshUser({
host,
user: other.user.trim(),
...(alias ? { alias } : {}),
...(other.key ? { key: other.key } : {}),
}),
t("gitIdentity.otherSaved", { host: alias || host })
);
if (ok) setOther({ host: "", user: "" });
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") => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="select !py-1 !text-xs"
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id && (!only || k.type === only))
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
);
const removeLogin = async (host: string) => {
if (
@@ -423,17 +485,20 @@ export function GitIdentitySettings() {
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.host} className="mt-1 flex items-center gap-2">
<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.region} · {r.user}
{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.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
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>
@@ -442,7 +507,7 @@ export function GitIdentitySettings() {
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
@@ -468,11 +533,22 @@ export function GitIdentitySettings() {
</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>
@@ -480,12 +556,119 @@ export function GitIdentitySettings() {
<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.host}>git clone ssh://{r.host}/v1/repos/&lt;repo&gt;</Code>
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
)}
</KeyCard>
{(named.length > 0 || id.writable) && (
<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>
)}
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
@@ -513,17 +696,20 @@ export function GitIdentitySettings() {
<div className="mt-1.5 space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => (
<div key={r.host} className="flex items-center gap-2">
<div key={r.alias ?? r.host} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.user}@{r.host}
{r.alias ? `ssh://${r.alias}/ → ` : ""}
{r.user}@{r.host} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
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>
@@ -532,7 +718,7 @@ export function GitIdentitySettings() {
))}
{id.writable && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveOther();
@@ -553,6 +739,16 @@ export function GitIdentitySettings() {
onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={other.alias}
onChange={(e) => setOther({ ...other, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(other.key, (key) => setOther({ ...other, key }))}
<button
type="submit"
className={BTN}
@@ -4,7 +4,9 @@
* first, and a member (S3) adds an HTTPS login of their own while the host's
* 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" — so the sections never mix.
* other host under "Other SSH hosts" — 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -21,6 +23,7 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(),
generateKey: vi.fn(),
removeKey: vi.fn(),
removeKeyById: vi.fn(),
setTerminalSsh: vi.fn(),
setSshUser: vi.fn(),
removeSshUser: vi.fn(),
@@ -143,7 +146,7 @@ describe("GitIdentitySettings", () => {
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")).toBeInTheDocument();
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
@@ -166,7 +169,7 @@ describe("GitIdentitySettings", () => {
host: "git.example.com",
user: "deploy",
});
expect(await screen.findByText("deploy@git.example.com")).toBeInTheDocument();
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
@@ -194,4 +197,63 @@ describe("GitIdentitySettings", () => {
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
});
it("makes a named key and saves a second AWS account under its name with that key", async () => {
const RSA = {
id: "id_rsa",
type: "rsa" as const,
name: null,
publicKey: "ssh-rsa A x",
fingerprint: "SHA256:r",
};
const PROD = {
id: "id_rsa_aws-prod",
type: "rsa" as const,
name: "aws-prod",
publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p",
};
const H = "git-codecommit.ap-southeast-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [RSA, PROD], sshUsers: [] })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
const more = await screen.findByRole("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");
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" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA2",
alias: "aws-prod",
key: "id_rsa_aws-prod",
});
expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
);
});
});