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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user