Skip to content

Commit 4556340

Browse files
authored
Merge pull request #60 from httpsphl/feat/delete-recycle
feat: send a deleted bot's or crew's folder to the Recycle Bin when asked
2 parents 4fdf715 + d436993 commit 4556340

60 files changed

Lines changed: 1830 additions & 44 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Settings, "Archived": the bots and crews the owner archived, which the
2+
// rest of the app does not show, with a way to delete each for good
3+
// (spec 7.6).
4+
5+
import { Users } from "lucide-react";
6+
import { type ReactNode, useEffect, useState } from "react";
7+
import { useT } from "../../i18n";
8+
import { errorText } from "../../lib/api";
9+
import { fromNow } from "../../lib/format";
10+
import type { Archive, Bot, Crew } from "../../lib/protocol.gen";
11+
import { useApi, useApp } from "../../store/context";
12+
import { Button } from "../../ui/Button";
13+
import { Callout } from "../../ui/Callout";
14+
import { BotAvatar } from "../bots/BotAvatar";
15+
import { DeleteBot } from "../bots/DeleteBot";
16+
import { DeleteCrew } from "../crews/DeleteCrew";
17+
import { Section } from "./settingsParts";
18+
19+
type Asking = { bot: Bot } | { crew: Crew } | null;
20+
21+
export function ArchivedSettings() {
22+
const s = useT().account.settings;
23+
const api = useApi();
24+
const active = useApp((state) => state.crews);
25+
const [archive, setArchive] = useState<Archive | null>(null);
26+
const [failed, setFailed] = useState<string | null>(null);
27+
const [asking, setAsking] = useState<Asking>(null);
28+
29+
useEffect(() => {
30+
let alive = true;
31+
api.call("archive.list").then(
32+
(list) => alive && setArchive(list),
33+
(error) => alive && setFailed(errorText(error)),
34+
);
35+
return () => {
36+
alive = false;
37+
};
38+
}, [api]);
39+
40+
if (failed) {
41+
return (
42+
<Section title={s.archived}>
43+
<Callout tone="danger" title={s.archivedLoadFailed}>
44+
{failed}
45+
</Callout>
46+
</Section>
47+
);
48+
}
49+
if (!archive) {
50+
return <Section title={s.archived}>{null}</Section>;
51+
}
52+
53+
// A bot of an archived crew goes with its crew; the others are listed.
54+
const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id);
55+
const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId));
56+
const forget = (gone: Asking) =>
57+
setArchive({
58+
crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)),
59+
bots: archive.bots.filter(
60+
(bot) =>
61+
!(gone && "bot" in gone && gone.bot.id === bot.id) &&
62+
!(gone && "crew" in gone && gone.crew.id === bot.crewId),
63+
),
64+
});
65+
66+
return (
67+
<Section title={s.archived}>
68+
<p className="text-ink-soft text-sm leading-relaxed">{s.archivedIntro}</p>
69+
{archive.crews.length + bots.length === 0 ? (
70+
<p className="text-muted text-sm">{s.archivedEmpty}</p>
71+
) : (
72+
<ul aria-label={s.archived} className="flex flex-col divide-y divide-line">
73+
{archive.crews.map((crew) => (
74+
<Row
75+
key={crew.id}
76+
icon={
77+
<span className="grid size-7 place-items-center rounded-full bg-sunken text-muted">
78+
<Users aria-hidden size={14} />
79+
</span>
80+
}
81+
name={crew.name}
82+
line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))}
83+
onDelete={() => setAsking({ crew })}
84+
/>
85+
))}
86+
{bots.map((bot) => (
87+
<Row
88+
key={bot.id}
89+
icon={<BotAvatar color={bot.color} size={28} />}
90+
name={bot.name}
91+
line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))}
92+
onDelete={() => setAsking({ bot })}
93+
/>
94+
))}
95+
</ul>
96+
)}
97+
{asking && "bot" in asking && (
98+
<DeleteBot
99+
bot={asking.bot}
100+
onClose={() => setAsking(null)}
101+
onDeleted={() => forget(asking)}
102+
/>
103+
)}
104+
{asking && "crew" in asking && (
105+
<DeleteCrew
106+
crew={asking.crew}
107+
bots={botsOf(asking.crew).length}
108+
onClose={() => setAsking(null)}
109+
onDeleted={() => forget(asking)}
110+
/>
111+
)}
112+
</Section>
113+
);
114+
}
115+
116+
function Row({
117+
icon,
118+
name,
119+
line,
120+
onDelete,
121+
}: {
122+
icon: ReactNode;
123+
name: string;
124+
line: string;
125+
onDelete(): void;
126+
}) {
127+
const s = useT().account.settings;
128+
return (
129+
<li className="flex items-center gap-3 py-2">
130+
{icon}
131+
<div className="min-w-0 flex-1">
132+
<p className="truncate font-medium text-sm">{name}</p>
133+
<p className="truncate text-muted text-xs">{line}</p>
134+
</div>
135+
<Button variant="danger" size="sm" label={s.deleteArchivedOne(name)} onClick={onDelete}>
136+
{s.deleteArchived}
137+
</Button>
138+
</li>
139+
);
140+
}

‎app/src/features/account/SettingsDialog.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Settings (spec 15.1), in parts listed on the left: general (what
22
// Botloft does in the background, the language), the chat, notifications,
3-
// the look, and the versions for when something needs reporting.
3+
// the look, what was archived, and the versions for when something needs
4+
// reporting.
45

56
import {
7+
Archive,
68
Bell,
79
Info,
810
type LucideIcon,
@@ -16,16 +18,18 @@ import { Dialog } from "../../ui/Dialog";
1618
import { AboutSettings } from "./AboutSettings";
1719
import { AlertsSettings } from "./AlertsSettings";
1820
import { AppearanceSettings } from "./AppearanceSettings";
21+
import { ArchivedSettings } from "./ArchivedSettings";
1922
import { ChatSettings } from "./ChatSettings";
2023
import { GeneralSettings } from "./GeneralSettings";
2124

22-
type Page = "general" | "chat" | "alerts" | "appearance" | "about";
25+
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about";
2326

2427
const PAGES: { id: Page; icon: LucideIcon }[] = [
2528
{ id: "general", icon: SlidersHorizontal },
2629
{ id: "chat", icon: MessageSquare },
2730
{ id: "alerts", icon: Bell },
2831
{ id: "appearance", icon: Palette },
32+
{ id: "archived", icon: Archive },
2933
{ id: "about", icon: Info },
3034
];
3135

@@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
7478
{page === "chat" && <ChatSettings />}
7579
{page === "alerts" && <AlertsSettings />}
7680
{page === "appearance" && <AppearanceSettings />}
81+
{page === "archived" && <ArchivedSettings />}
7782
{page === "about" && <AboutSettings />}
7883
</div>
7984
</div>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { RpcError } from "../../lib/rpc";
5+
import { crewOpened, renderApp } from "../../test/app";
6+
7+
afterEach(cleanup);
8+
9+
/** Opens Settings on its "Archived" part. */
10+
async function openArchived() {
11+
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
12+
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
13+
const dialog = screen.getByRole("dialog", { name: "Settings" });
14+
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
15+
return dialog;
16+
}
17+
18+
/** Ops with Writer and an archived Scout, and an archived crew Docs of one bot. */
19+
async function someArchived() {
20+
const fake = new FakeBotloft();
21+
const ops = fake.addCrew("Ops");
22+
const docs = fake.addCrew("Docs");
23+
const scout = fake.addBot(ops.id, "Scout");
24+
fake.addBot(ops.id, "Writer");
25+
const editor = fake.addBot(docs.id, "Editor");
26+
await fake.call("bots.archive", { botId: scout.id });
27+
await fake.call("crews.archive", { crewId: docs.id });
28+
renderApp(fake);
29+
await crewOpened("Ops");
30+
return { fake, ops, docs, scout, editor };
31+
}
32+
33+
describe("settings, archived", () => {
34+
test("lists what was archived, which the app shows nowhere else", async () => {
35+
await someArchived();
36+
const dialog = await openArchived();
37+
const list = await within(dialog).findByRole("list", { name: "Archived" });
38+
const rows = within(list).getAllByRole("listitem");
39+
expect(rows.map((row) => row.textContent)).toEqual([
40+
"DocsCrew · 1 bot · archived nowDelete",
41+
"ScoutBot in Ops · archived nowDelete",
42+
]);
43+
// A bot of an archived crew goes with its crew, not on its own.
44+
expect(within(list).queryByText("Editor")).toBeNull();
45+
expect(dialog.textContent).toContain("Botloft still keeps their conversations");
46+
});
47+
48+
test("deletes an archived bot, saying it leaves rather than stops", async () => {
49+
const { fake, scout } = await someArchived();
50+
const dialog = await openArchived();
51+
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Scout" }));
52+
const confirm = screen.getByRole("dialog", { name: "Delete Scout?" });
53+
expect(confirm.textContent).toContain("Scout leaves Botloft for good");
54+
expect(confirm.textContent).not.toContain("stops now");
55+
expect(within(confirm).getByText(scout.workspace)).toBeDefined();
56+
fireEvent.click(within(confirm).getByRole("button", { name: "Delete bot" }));
57+
await waitFor(() => expect(within(dialog).queryByText("Scout")).toBeNull());
58+
expect(fake.bots.has(scout.id)).toBe(false);
59+
expect(within(dialog).getByText("Docs")).toBeDefined();
60+
});
61+
62+
test("deletes an archived crew with its bots, then has nothing left", async () => {
63+
const { fake, docs, scout, editor } = await someArchived();
64+
await fake.call("bots.delete", { botId: scout.id });
65+
const dialog = await openArchived();
66+
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Docs" }));
67+
const confirm = screen.getByRole("dialog", { name: "Delete Docs?" });
68+
expect(confirm.textContent).toContain("Docs and its bot leave Botloft for good");
69+
expect(within(confirm).getByText(docs.workFolder)).toBeDefined();
70+
fireEvent.click(within(confirm).getByRole("button", { name: "Delete crew" }));
71+
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();
72+
expect(fake.crews.has(docs.id)).toBe(false);
73+
expect(fake.bots.has(editor.id)).toBe(false);
74+
});
75+
76+
test("says so when nothing is archived, or when the list cannot be read", async () => {
77+
const fake = new FakeBotloft();
78+
fake.addCrew("Ops");
79+
renderApp(fake);
80+
await crewOpened("Ops");
81+
const dialog = await openArchived();
82+
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();
83+
84+
fake.failNext("archive.list", new RpcError(-32603, "internal error"));
85+
fireEvent.click(within(dialog).getByRole("tab", { name: "About" }));
86+
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
87+
const alert = await within(dialog).findByRole("alert");
88+
expect(alert.textContent).toContain("Could not load what is archived");
89+
});
90+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useState } from "react";
2+
import { useT } from "../../i18n";
3+
import type { Bot } from "../../lib/protocol.gen";
4+
import { useApi, useApp } from "../../store/context";
5+
import { Confirm } from "../../ui/Confirm";
6+
import { KeptFolder } from "../../ui/KeptFolder";
7+
import { attempt } from "../../ui/toast";
8+
9+
/**
10+
* Asks before deleting a bot for good (spec 7.6), saying what goes and
11+
* what stays, with the choice to send its folder to the Recycle Bin.
12+
* `chiefOf` is the crew it leads, if it leads one.
13+
*/
14+
export function DeleteBot({
15+
bot,
16+
chiefOf,
17+
onClose,
18+
onDeleted,
19+
}: {
20+
bot: Bot;
21+
chiefOf?: string | undefined;
22+
onClose(): void;
23+
onDeleted?(): void;
24+
}) {
25+
const words = useT().bots.header;
26+
const api = useApi();
27+
const dropBot = useApp((state) => state.dropBot);
28+
// The folder stays unless the owner says otherwise.
29+
const [recycle, setRecycle] = useState(false);
30+
return (
31+
<Confirm
32+
title={words.deleteConfirm.title(bot.name)}
33+
confirmLabel={words.deleteConfirm.confirm}
34+
onClose={onClose}
35+
onConfirm={() =>
36+
attempt(words.failed.delete, async () => {
37+
const asked = recycle ? { botId: bot.id, recycleFolder: true } : { botId: bot.id };
38+
dropBot((await api.call("bots.delete", asked)).botId);
39+
onDeleted?.();
40+
})
41+
}
42+
>
43+
<p>
44+
{words.deleteConfirm.removed(bot.name, bot.archivedAt === null)}
45+
{chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`}
46+
</p>
47+
<KeptFolder
48+
path={bot.workspace}
49+
recycle={{ label: words.deleteConfirm.recycle, checked: recycle, onChange: setRecycle }}
50+
>
51+
{recycle ? words.deleteConfirm.recycled(bot.name) : words.deleteConfirm.kept(bot.name)}
52+
</KeptFolder>
53+
</Confirm>
54+
);
55+
}

‎app/src/features/bots/botActions.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Archive, Crown, FolderOpen, Pencil, RefreshCcw } from "lucide-react";
1+
import { Archive, Crown, FolderOpen, Pencil, RefreshCcw, Trash2 } from "lucide-react";
22
import { type ReactNode, useState } from "react";
33
import { useT } from "../../i18n";
44
import type { Bot, Crew } from "../../lib/protocol.gen";
@@ -8,8 +8,9 @@ import type { MenuItem } from "../../ui/Menu";
88
import { attempt } from "../../ui/toast";
99
import { BotDialog } from "./BotDialog";
1010
import { isChief } from "./ChiefBadge";
11+
import { DeleteBot } from "./DeleteBot";
1112

12-
type Open = "edit" | "archive" | "fresh" | null;
13+
type Open = "edit" | "archive" | "delete" | "fresh" | null;
1314

1415
/**
1516
* What the owner can do with a bot from a menu, and the dialogs those
@@ -54,6 +55,7 @@ export function useBotActions(
5455
onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)),
5556
},
5657
{ label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") },
58+
{ label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") },
5759
];
5860

5961
const dialogs =
@@ -85,6 +87,8 @@ export function useBotActions(
8587
>
8688
{words.archiveConfirm.body}
8789
</Confirm>
90+
) : open === "delete" ? (
91+
<DeleteBot bot={bot} chiefOf={chief ? crew.name : undefined} onClose={close} />
8892
) : null;
8993

9094
return { items, dialogs };

0 commit comments

Comments
 (0)