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
+55 -7
View File
@@ -11,7 +11,8 @@
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
* installs off for everyone. Previews of a file are
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
* shared HTML file carries runs on the dashboard's origin.
* shared HTML file carries runs on the dashboard's origin. Right-clicking a
* card offers the same actions as its buttons, plus copying a page's link.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,6 +44,8 @@ import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { copyText } from "../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import {
FileUploadPanel,
PluginAddPanel,
@@ -325,12 +328,20 @@ function ArtifactCard({
}) {
const { t } = useTranslation("artifacts");
const Icon = KIND_ICON[a.kind];
const menu = useContextMenu<null>();
const pick = (fn: () => void) => () => {
menu.close();
fn();
};
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
const canPreview = (a.kind === "file" || a.kind === "skill") && a.files.length > 0;
const btn =
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
return (
<li
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
data-testid={`artifact-${a.id}`}
onContextMenu={(e) => menu.open(e, null)}
>
<div className="flex items-start gap-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
@@ -363,12 +374,8 @@ function ArtifactCard({
{t("open")}
</a>
)}
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && (
<button
type="button"
className={btn}
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
>
{canPreview && (
<button type="button" className={btn} onClick={() => onPreview(previewPath)}>
<Eye className="h-3 w-3" />
{t("preview")}
</button>
@@ -410,6 +417,47 @@ function ArtifactCard({
)}
</span>
</div>
{menu.menu && (
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
{a.kind === "page" && a.url && (
<>
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
{t("open")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
</>
)}
{canPreview && (
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
)}
{a.kind === "file" && a.files.length > 0 && (
<MenuItem
onSelect={pick(() => {
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
})}
>
{t("download")}
</MenuItem>
)}
{(a.kind === "skill" || a.kind === "plugin") && (
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
{t("install")}
</MenuItem>
)}
{a.canChange && (
<>
<MenuSeparator />
{server && (
<MenuItem onSelect={pick(onShare)}>{t(a.shared ? "unshare" : "share")}</MenuItem>
)}
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
<MenuItem danger onSelect={pick(onDelete)}>
{t("delete")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</li>
);
}