Skip to content
Source
Sign inSign up
Open History to see the last commit

ArchivedThreadsPanel.tsx

275 lines10.9 KB

1import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
2import { Spinner } from "~/components/ui/spinner";
3import { ArchiveIcon, ArchiveX, Trash2Icon } from "lucide-react";
4import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
5import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
6import {
7 type AtomCommandResult,
8 isAtomCommandInterrupted,
9 settlePromise,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12import { useSettingsScope } from "./SettingsScopeContext";
13import { useThreadActions } from "../../hooks/useThreadActions";
14import { readLocalApi } from "../../localApi";
15import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
16import { RelativeTime } from "../RelativeTime";
17import { Button } from "../ui/button";
18import { stackedThreadToast, toastManager } from "../ui/toast";
19import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
20import { searchableSetting } from "./settingsSearch";
21import { ProjectFavicon } from "../ProjectFavicon";
22​
23import { useRef, useState } from "react";
24import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
25​
26const ARCHIVE_ACTIONS = [
27 { action: "unarchive", label: "Unarchive", Icon: ArchiveX },
28 { action: "delete", label: "Delete", Icon: Trash2Icon },
29] as const;
30type ArchiveAction = (typeof ARCHIVE_ACTIONS)[number]["action"];
31​
32export function ArchivedThreadsPanel() {
33 const { scope, groups: projectGroups, environments } = useSettingsScope();
34 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
35 const pendingActionsRef = useRef(new Set<string>());
36 const [pendingKeys, setPendingKeys] = useState<ReadonlySet<string>>(() => new Set());
37 const {
38 snapshots: archivedSnapshots,
39 error: archiveError,
40 isLoading: isLoadingArchive,
41 refresh: refreshArchivedThreads,
42 } = useArchivedThreadSnapshots(scope.environmentIds);
43​
44 const { groups: archivedGroups, hasMultipleEnvironments } = (() => {
45 const selectedProjectKeys =
46 scope.kind === "project" || scope.kind === "checkout"
47 ? new Set(scope.members.map((member) => `${member.environmentId}:${member.id}`))
48 : null;
49 const projectsByEnvironmentAndId = new Map(
50 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
51 snapshot.projects
52 .filter(
53 (project) =>
54 selectedProjectKeys === null ||
55 selectedProjectKeys.has(`${environmentId}:${project.id}`),
56 )
57 .map(
58 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
59 ),
60 ),
61 );
62 const environmentLabels = new Map(
63 environments.map((environment) => {
64 const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments);
65 return [environment.environmentId, secondary ? `${primary} (${secondary})` : primary];
66 }),
67 );
68 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
69 snapshot.threads.map((thread) => ({
70 ...presentThreadShell(environmentId, thread),
71 environmentLabel: environmentLabels.get(environmentId) ?? environmentId,
72 })),
73 );
74 const hasMultipleEnvironments =
75 new Set(
76 threads
77 .filter((thread) =>
78 projectsByEnvironmentAndId.has(`${thread.environmentId}:${thread.projectId}`),
79 )
80 .map((thread) => thread.environmentId),
81 ).size > 1;
82 const logicalGroupsByProjectKey = new Map(
83 projectGroups.flatMap((group) =>
84 group.memberProjectRefs.map(
85 (member) => [`${member.environmentId}:${member.projectId}`, group] as const,
86 ),
87 ),
88 );
89​
90 const archivedProjects = Array.from(projectsByEnvironmentAndId.values());
91 const groups = new Map<
92 string,
93 {
94 readonly project: (typeof archivedProjects)[number];
95 readonly title: string;
96 readonly threads: Array<(typeof threads)[number]>;
97 }
98 >();
99 for (const project of archivedProjects) {
100 const projectThreads: Array<(typeof threads)[number]> = [];
101 for (const thread of threads) {
102 if (thread.projectId === project.id && thread.environmentId === project.environmentId) {
103 projectThreads.push(thread);
104 }
105 }
106 if (projectThreads.length > 0) {
107 const physicalProjectKey = `${project.environmentId}:${project.id}` as const;
108 const logicalGroup = hasMultipleEnvironments
109 ? logicalGroupsByProjectKey.get(physicalProjectKey)
110 : undefined;
111 const groupKey = logicalGroup?.projectKey ?? physicalProjectKey;
112 const group = groups.get(groupKey);
113 if (group) {
114 group.threads.push(...projectThreads);
115 } else {
116 groups.set(groupKey, {
117 project,
118 title: logicalGroup?.displayName ?? project.title,
119 threads: projectThreads,
120 });
121 }
122 }
123 }
124 const sortedGroups = Array.from(groups, ([key, group]) => ({
125 ...group,
126 key,
127 threads: group.threads.toSorted((left, right) => {
128 const leftKey = left.archivedAt ?? left.createdAt;
129 const rightKey = right.archivedAt ?? right.createdAt;
130 return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id);
131 }),
132 }));
133 return { groups: sortedGroups, hasMultipleEnvironments };
134 })();
135​
136 type ArchivedThread = (typeof archivedGroups)[number]["threads"][number];
137​
138 const reportFailure = (result: AtomCommandResult<unknown, unknown> | null, title: string) => {
139 if (result?._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
140 const error = squashAtomCommandFailure(result);
141 toastManager.add(
142 stackedThreadToast({
143 type: "error",
144 title,
145 description: error instanceof Error ? error.message : "An error occurred.",
146 }),
147 );
148 };
149​
150 const handleThreadAction = async (action: ArchiveAction, thread: ArchivedThread) => {
151 const target = scopeThreadRef(thread.environmentId, thread.id);
152 const key = scopedThreadKey(target);
153 // Context menus can outlive their render, so checks and cleanup use the live set.
154 const pending = pendingActionsRef.current;
155 if (pending.has(key)) return;
156 pending.add(key);
157 setPendingKeys(new Set(pending));
158 const result = await settlePromise(async () => {
159 const result = await (action === "unarchive"
160 ? unarchiveThread(target)
161 : confirmAndDeleteThread(target, thread.title));
162 if (result._tag === "Success") refreshArchivedThreads();
163 return result;
164 }).finally(() => {
165 pending.delete(key);
166 setPendingKeys(new Set(pending));
167 });
168 reportFailure(result._tag === "Failure" ? result : result.value, `Failed to ${action} thread`);
169 };
170​
171 const handleContextMenu = async (thread: ArchivedThread, position: { x: number; y: number }) => {
172 const pending = pendingActionsRef.current;
173 if (pending.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))) return;
174 const result = await settlePromise(async () => {
175 const clicked = await readLocalApi()?.contextMenu.show(
176 ARCHIVE_ACTIONS.map(({ action, label }) => ({
177 id: action,
178 label,
179 destructive: action === "delete",
180 })),
181 position,
182 );
183 if (clicked === "unarchive" || clicked === "delete")
184 await handleThreadAction(clicked, thread);
185 });
186 reportFailure(result, "Archived thread action failed");
187 };
188​
189 return (
190 <SettingsPageContainer>
191 {archivedGroups.length === 0 ? (
192 <SettingsSection
193 id={isLoadingArchive ? undefined : searchableSetting("archive").id}
194 title={searchableSetting("archive").title}
195 >
196 <SettingsRow
197 title={
198 <span className="inline-flex items-center gap-2">
199 {isLoadingArchive ? (
200 <Spinner size="sm" tone="muted" />
201 ) : (
202 <ArchiveIcon className="size-3.5 text-muted-foreground" />
203 )}
204 {isLoadingArchive
205 ? "Loading archived threads"
206 : archiveError
207 ? "Could not load archived threads"
208 : "No archived threads"}
209 </span>
210 }
211 description={
212 isLoadingArchive
213 ? "Checking connected environments."
214 : (archiveError ?? "Archived threads will appear here.")
215 }
216 />
217 </SettingsSection>
218 ) : (
219 archivedGroups.map(({ key, project, title, threads: projectThreads }, index) => (
220 <SettingsSection
221 key={key}
222 id={index === 0 ? searchableSetting("archive").id : undefined}
223 title={title}
224 icon={<ProjectFavicon project={project} />}
225 >
226 {projectThreads.map((thread) => {
227 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
228 return (
229 <SettingsRow
230 key={`${thread.environmentId}:${thread.id}`}
231 onContextMenu={(event) => {
232 event.preventDefault();
233 void handleContextMenu(thread, { x: event.clientX, y: event.clientY });
234 }}
235 title={thread.title}
236 description={
237 <span className={hasMultipleEnvironments ? "block truncate" : undefined}>
238 {hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null}
239 Archived <RelativeTime timestamp={thread.archivedAt ?? thread.createdAt} />
240 {" \u00b7 Created "}
241 <RelativeTime timestamp={thread.createdAt} />
242 </span>
243 }
244 control={
245 <div className="flex shrink-0 items-center gap-2">
246 {ARCHIVE_ACTIONS.map(({ action, label, Icon }) => (
247 <Tooltip key={action}>
248 <TooltipTrigger
249 render={
250 <Button
251 type="button"
252 variant={action === "delete" ? "destructive-outline" : "outline"}
253 size="icon-sm"
254 aria-label={`${label} ${thread.title}`}
255 disabled={pendingKeys.has(threadKey)}
256 onClick={() => void handleThreadAction(action, thread)}
257 />
258 }
259 >
260 <Icon className="size-3.5" />
261 </TooltipTrigger>
262 <TooltipPopup>{label}</TooltipPopup>
263 </Tooltip>
264 ))}
265 </div>
266 }
267 />
268 );
269 })}
270 </SettingsSection>
271 ))
272 )}
273 </SettingsPageContainer>
274 );