chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable one showing its error); a folder opens that user's Trash (path bar Trash › user, Up back to the folders). An admin may restore/purge in another user's Trash (audited); purging asks first. The merged "All users" list is gone from the UI. - A home's Trash is its user's whole recycle bin (home store + host store, items tagged `store`). - `agenthub server setup` installs one Linux user per account on Linux by default (`--no-system` opts out); install-system messages drop `sudo`. - Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,12 @@
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||
* Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js).
|
||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
* without waiting the 7 days.
|
||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||
* (server/lib/file-explorer.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -82,6 +87,7 @@ import {
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -250,6 +256,10 @@ export function FileBrowser({
|
||||
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
const [trash, setTrash] = useState<FsTrashItem[]>([]);
|
||||
// An admin's Trash opens on its root: one folder per user's Trash, counted
|
||||
// from `GET /api/homes/trash` (a Trash that could not be read: its error).
|
||||
const [trashRoot, setTrashRoot] = useState(false);
|
||||
const [trashErrors, setTrashErrors] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||
@@ -330,7 +340,12 @@ export function FileBrowser({
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
if (view === "trash" && trashRoot) {
|
||||
const out = await api.files.allTrash();
|
||||
setTrash(out.items);
|
||||
// A user whose trash could not be read is said so on their folder.
|
||||
setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message])));
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
@@ -338,10 +353,11 @@ export function FileBrowser({
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
);
|
||||
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
|
||||
if (view === "trash") setTrash([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [target, path, view, t]);
|
||||
}, [target, path, view, trashRoot, t]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -635,11 +651,70 @@ export function FileBrowser({
|
||||
|
||||
const homeName = (who: string) =>
|
||||
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
|
||||
/** Delete forever — asked first when it is all of it or someone else's. */
|
||||
const purge = async (id: string) => {
|
||||
if (id === "all" || ro) {
|
||||
const item = trash.find((i) => i.id === id);
|
||||
const ok = await confirmDialog({
|
||||
title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"),
|
||||
message:
|
||||
id === "all"
|
||||
? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel })
|
||||
: t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }),
|
||||
confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
const item = trash.find((i) => i.id === id);
|
||||
await run(() => api.files.purge(target, id, item?.store), 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"));
|
||||
};
|
||||
/**
|
||||
* Open a Trash. Every user has ONE recycle bin — their home's, over every
|
||||
* place they deleted in — so `home` opens that user's ("me": the caller's);
|
||||
* null (no home to open) falls back to the trash of the place shown, and
|
||||
* "*" opens the root an admin starts on: one folder per user's Trash.
|
||||
*/
|
||||
const openTrash = (home: string | null) => {
|
||||
if (home === "*") {
|
||||
setTrashRoot(true);
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
return;
|
||||
}
|
||||
setTrashRoot(false);
|
||||
if (home && !(isHomePlace(target) && target.home === home)) {
|
||||
setTarget({ home });
|
||||
setPathState("");
|
||||
setHistory({ back: [], fwd: [] });
|
||||
onNavigate?.({ home }, "");
|
||||
}
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
};
|
||||
// Someone else's Trash can be opened: the Trash starts on its folders.
|
||||
const manyTrashes = homes.some((h) => h.id !== "me");
|
||||
const openTrashes = () =>
|
||||
openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null);
|
||||
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
|
||||
const laneName = (id: number) => {
|
||||
const l = lanes.find((x) => x.id === id);
|
||||
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
|
||||
};
|
||||
// A home's Trash is its USER's recycle bin, named after them: another
|
||||
// user's shows THAT user's (an admin may restore from or purge it; the rest
|
||||
// of the home stays read-only). A workspace's (no home to open) is the
|
||||
// trash of whoever owns its folder.
|
||||
const trashLabel = trashRoot
|
||||
? t("fx.trash")
|
||||
: !isHomePlace(target)
|
||||
? t("fx.trashAt", { name: laneName(laneOf(target)!) })
|
||||
: target.home === "me"
|
||||
? t("fx.myTrash")
|
||||
: t("fx.trashOf", { name: homeName(target.home) });
|
||||
const crumbs = path ? path.split("/") : [];
|
||||
const menuItem = ctx.menu?.item ?? null;
|
||||
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
|
||||
@@ -748,17 +823,10 @@ export function FileBrowser({
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={placeClass(view === "trash")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("fx.trash")}
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.trash")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -774,8 +842,8 @@ export function FileBrowser({
|
||||
</HeadButton>
|
||||
<HeadButton
|
||||
label={t("fx.up")}
|
||||
disabled={view !== "files" || listing?.parent == null}
|
||||
onClick={goUp}
|
||||
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
|
||||
onClick={view === "trash" ? () => openTrash("*") : goUp}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
@@ -805,9 +873,29 @@ export function FileBrowser({
|
||||
aria-label={t("fx.location")}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
|
||||
>
|
||||
{view === "trash" ? (
|
||||
{view === "trash" && manyTrashes ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTrash("*")}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
trashRoot ? "font-semibold" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
||||
</button>
|
||||
{!trashRoot && (
|
||||
<span className="flex shrink-0 items-center">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="px-2 py-1 text-sm font-semibold">
|
||||
{isHomePlace(target) ? homeName(target.home) : trashLabel}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : view === "trash" ? (
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
|
||||
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
||||
<Trash2 className="h-4 w-4" /> {trashLabel}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -1169,12 +1257,23 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : trashRoot ? (
|
||||
<TrashFolders
|
||||
homes={homes}
|
||||
items={trash}
|
||||
errors={trashErrors}
|
||||
error={error}
|
||||
loading={loading}
|
||||
homeName={homeName}
|
||||
onOpen={openTrash}
|
||||
/>
|
||||
) : (
|
||||
<TrashView
|
||||
items={trash}
|
||||
error={error}
|
||||
laneName={laneName}
|
||||
onRestore={(id) => void run(() => api.files.restore(target, id), t("fx.restored"))}
|
||||
onPurge={(id) => void run(() => api.files.purge(target, id))}
|
||||
onRestore={restoreItem}
|
||||
onPurge={(id) => void purge(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1651,13 +1750,102 @@ function InlineName({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An admin's Trash root: one folder per user's Trash (theirs first), each
|
||||
* with its item count — or why it could not be read. Opening one shows that
|
||||
* user's Trash, where its items are restored or deleted forever.
|
||||
*/
|
||||
function TrashFolders({
|
||||
homes,
|
||||
items,
|
||||
errors,
|
||||
error,
|
||||
loading,
|
||||
homeName,
|
||||
onOpen,
|
||||
}: {
|
||||
homes: FsHome[];
|
||||
/** Every Trash's items, merged, each tagged with its `home`. */
|
||||
items: FsTrashItem[];
|
||||
/** Per home id: why its Trash could not be read. */
|
||||
errors: Record<string, string>;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
homeName: (id: string) => string;
|
||||
onOpen: (home: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
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="border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||
{t("fx.trashFoldersHint")}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2 overflow-y-auto p-4">
|
||||
{ordered.map((h) => {
|
||||
const n = counts.get(h.id) ?? 0;
|
||||
const failed = errors[h.id];
|
||||
const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id);
|
||||
return (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
|
||||
onClick={() => onOpen(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"
|
||||
>
|
||||
<span className="relative">
|
||||
<Folder
|
||||
className={`h-14 w-14 ${
|
||||
n ? "fill-accent/25 text-accent" : "fill-surface-3 text-fg-muted"
|
||||
}`}
|
||||
/>
|
||||
{h.id === "me" ? (
|
||||
<House className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||
) : (
|
||||
<UserRound className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||
)}
|
||||
</span>
|
||||
<span className="w-full truncate text-[13px] font-medium">{name}</span>
|
||||
<span
|
||||
className={`w-full truncate text-[11px] ${
|
||||
failed ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
title={failed}
|
||||
>
|
||||
{failed
|
||||
? failed
|
||||
: loading
|
||||
? "…"
|
||||
: n
|
||||
? t("fx.itemsCount", { count: n })
|
||||
: t("fx.trashFolderEmpty")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashView({
|
||||
items,
|
||||
error,
|
||||
laneName,
|
||||
onRestore,
|
||||
onPurge,
|
||||
}: {
|
||||
items: FsTrashItem[];
|
||||
error: string | null;
|
||||
laneName: (id: number) => string;
|
||||
onRestore: (id: string) => void;
|
||||
onPurge: (id: string) => void;
|
||||
@@ -1665,6 +1853,11 @@ function TrashView({
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||
<span className="flex-1">{t("fx.trashHint")}</span>
|
||||
<button
|
||||
@@ -1732,7 +1925,7 @@ function TrashView({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!items.length && (
|
||||
{!items.length && !error && (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
|
||||
<Trash2 className="h-14 w-14 opacity-40" />
|
||||
<p className="text-sm">{t("fx.trashEmpty")}</p>
|
||||
|
||||
Reference in New Issue
Block a user