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>
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||
* batch over 500 MB before sending anything, and restores from the Trash.
|
||||
* batch over 500 MB before sending anything, restores from the user's own
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -12,6 +16,9 @@ import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
@@ -32,6 +39,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
copy: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
trash: vi.fn(),
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
@@ -70,6 +78,7 @@ beforeEach(() => {
|
||||
} as never);
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
|
||||
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [
|
||||
{ id: "me", label: "An", readOnly: false },
|
||||
@@ -212,10 +221,93 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(screen.getByRole("button", { name: "Trash" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
|
||||
await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it("opens an admin's Trash on one folder per user, each opening that user's Trash", 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: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
|
||||
});
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const trashes = screen.getByTestId("fx-trashes");
|
||||
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
|
||||
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
|
||||
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
|
||||
await user.click(folders[1]!);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
|
||||
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
|
||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
|
||||
)
|
||||
);
|
||||
expect(api.files.purge).not.toHaveBeenCalled();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
// Back to the folders through the path bar.
|
||||
await user.click(
|
||||
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
|
||||
name: "Trash",
|
||||
})
|
||||
);
|
||||
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
|
||||
});
|
||||
|
||||
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();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
|
||||
expect(screen.queryByText("The Trash is empty.")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
|
||||
@@ -242,7 +334,7 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(await screen.findByRole("button", { name: /Bob/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Bob" }));
|
||||
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* @file GitIdentitySettings.tsx
|
||||
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
||||
* commit author (name + email in the account's global git config — prefilled
|
||||
* from Settings → Account while unset, since a member's commits fail without
|
||||
* one), the SSH
|
||||
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
|
||||
* since a member's commits fail without one; a new SSH key is labelled with
|
||||
* that email), the SSH
|
||||
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it, and whether git typed in the account's own terminal uses it too (a
|
||||
@@ -308,7 +309,7 @@ export function GitIdentitySettings() {
|
||||
const member = id.account === "member";
|
||||
const authorShown = author ?? {
|
||||
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
||||
email: id.author.email ?? me?.email ?? "",
|
||||
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
|
||||
};
|
||||
const authorUnset = !id.author.name || !id.author.email;
|
||||
const authorDirty =
|
||||
|
||||
Reference in New Issue
Block a user