1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
4import { CheckIcon, MessageSquareIcon } from "lucide-react";
5import { useState } from "react";
6import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
7import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
8
9import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
10import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
11import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";
12import { pullRequestEnvironment } from "~/state/pullRequests";
13import { useEnvironmentQuery } from "~/state/query";
14import { appAtomRegistry } from "~/rpc/atomRegistry";
15import { openCommandPalette } from "~/commandPaletteBus";
16import { Button } from "../ui/button";
17import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";
18import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
19import { MenuItem } from "../ui/menu";
20import { toastManager } from "../ui/toast";
21import { PullRequestGlyph } from "./pullRequestIcons";
22
23interface PullRequestThreadLinksProps {
24 environmentId: EnvironmentId;
25 reference: PullRequestRef;
26 url: string;
27 threadRef: ScopedThreadRef | null;
28 display: "count" | "menu-item" | "picker";
29 onPickerOpenChange?: (open: boolean) => void;
30}
31
32/** Thread relations belong to the detail environment, including when another environment is active. */
33export function PullRequestThreadLinks(props: PullRequestThreadLinksProps) {
34 const configs = useServerConfigs();
35 if (
36 threadPullRequestLinkMode(configs.get(props.environmentId)?.environment.capabilities) ===
37 "unsupported"
38 ) {
39 return null;
40 }
41 return <EnabledPullRequestThreadLinks key={`${props.environmentId}:${props.url}`} {...props} />;
42}
43
44function EnabledPullRequestThreadLinks({
45 environmentId,
46 reference,
47 url,
48 threadRef,
49 display,
50 onPickerOpenChange,
51}: PullRequestThreadLinksProps) {
52 const parsed = parseChangeRequestUrl(url);
53 const thread = useThreadShell(threadRef?.environmentId === environmentId ? threadRef : null);
54 const currentThreadRef = thread === null ? null : threadRef;
55 const linking = usePullRequestLinking(environmentId);
56 const linkedHere = linking.isLinked(thread, url);
57 const relations = useEnvironmentQuery(
58 linking.mode === "multiple" && display !== "menu-item"
59 ? pullRequestEnvironment.linkedThreads({
60 environmentId,
61 input:
62 parsed === null
63 ? reference
64 : { ...reference, ...normalizeThreadPullRequestKey(parsed) },
65 })
66 : null,
67 );
68 // Refreshes can briefly clear the query value. Keep the last response so polling
69 // does not hide the linked-thread count between responses.
70 const [lastRelations, setLastRelations] = useState(relations.data);
71 if (relations.data !== null && relations.data !== lastRelations) {
72 setLastRelations(relations.data);
73 }
74 const [pending, setPending] = useState(false);
75
76 const changeLink = async (threadId: ThreadId, remove: boolean) => {
77 if (parsed === null || pending) return;
78 setPending(true);
79 try {
80 await linking.changeLink(scopeThreadRef(environmentId, threadId), url, !remove);
81 } catch (error) {
82 toastManager.add({
83 type: "error",
84 title: remove ? "Could not unlink the pull request" : "Could not link the pull request",
85 description: error instanceof Error ? error.message : String(error),
86 });
87 return;
88 } finally {
89 setPending(false);
90 }
91 if (linking.mode === "multiple") {
92 appAtomRegistry.refresh(
93 pullRequestEnvironment.linkedThreads({
94 environmentId,
95 input: { ...reference, ...normalizeThreadPullRequestKey(parsed) },
96 }),
97 );
98 }
99 onPickerOpenChange?.(false);
100 };
101
102 if (parsed === null || (!linkedHere && !linking.canLink(url))) return null;
103 const linkedThreads =
104 linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : [];
105 const linkedThreadsLabel =
106 linkedThreads.length > 0
107 ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}`
108 : "Linked threads";
109 return (
110 <>
111 {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? (
112 <Tooltip>
113 <TooltipTrigger
114 render={
115 <Button
116 size="xs"
117 variant="ghost"
118 aria-label={linkedThreadsLabel}
119 onClick={() =>
120 openCommandPalette({
121 query: url,
122 ...((relations.data ?? lastRelations) === null
123 ? {}
124 : { linkedThreads: { environmentId, threads: linkedThreads } }),
125 })
126 }
127 >
128 <MessageSquareIcon aria-hidden className="size-3.5" />
129 <span aria-hidden>{linkedThreads.length || "?"}</span>
130 </Button>
131 }
132 />
133 <TooltipPopup>{linkedThreadsLabel}. Search in the command palette.</TooltipPopup>
134 </Tooltip>
135 ) : null}
136 {display === "menu-item" ? (
137 <MenuItem
138 disabled={pending}
139 onClick={() => {
140 if (currentThreadRef !== null) {
141 void changeLink(currentThreadRef.threadId, linkedHere);
142 } else {
143 onPickerOpenChange?.(true);
144 }
145 }}
146 >
147 {linkedHere ? (
148 <PullRequestGlyph.unlink aria-hidden className="size-3.5" />
149 ) : (
150 <PullRequestGlyph.link aria-hidden className="size-3.5" />
151 )}
152 {linkedHere
153 ? "Unlink from this thread"
154 : currentThreadRef
155 ? "Link to this thread"
156 : "Link to thread"}
157 </MenuItem>
158 ) : null}
159 {display === "picker" ? (
160 <Dialog open onOpenChange={onPickerOpenChange}>
161 <DialogPopup className="max-w-md" showCloseButton={false}>
162 <DialogTitle className="sr-only">Link pull request to a thread</DialogTitle>
163 <ThreadPicker
164 environmentId={environmentId}
165 url={url}
166 pending={pending}
167 onSelect={(threadId) => void changeLink(threadId, false)}
168 />
169 </DialogPopup>
170 </Dialog>
171 ) : null}
172 </>
173 );
174}
175
176function ThreadPicker({
177 environmentId,
178 url,
179 pending,
180 onSelect,
181}: {
182 environmentId: EnvironmentId;
183 url: string;
184 pending: boolean;
185 onSelect: (threadId: ThreadId) => void;
186}) {
187 const threads = useThreadShells();
188 const linking = usePullRequestLinking(environmentId);
189 const projects = useProjects();
190 const [query, setQuery] = useState("");
191 const projectNames = new Map(
192 projects
193 .filter((project) => project.environmentId === environmentId)
194 .map((project) => [project.id, project.title]),
195 );
196 const search = query.trim().toLocaleLowerCase();
197 const candidates = threads
198 .filter(
199 (thread) =>
200 thread.environmentId === environmentId &&
201 thread.archivedAt === null &&
202 `${thread.title} ${projectNames.get(thread.projectId) ?? ""}`
203 .toLocaleLowerCase()
204 .includes(search),
205 )
206 .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt));
207 return (
208 <Command mode="none" value={query} onValueChange={setQuery} aria-label="Choose a thread">
209 <CommandInput placeholder="Search threads or projects..." disabled={pending} />
210 <CommandList className="max-h-80 overflow-y-auto">
211 {candidates.length === 0 ? (
212 <div className="px-3 py-6 text-center text-sm text-muted-foreground">
213 No active threads found.
214 </div>
215 ) : (
216 candidates.map((thread) => {
217 const linked = linking.isLinked(thread, url);
218 return (
219 <CommandItem
220 key={thread.id}
221 value={thread.id}
222 disabled={pending || linked}
223 onClick={() => onSelect(thread.id)}
224 >
225 <MessageSquareIcon aria-hidden className="size-4 shrink-0" />
226 <span className="flex min-w-0 flex-1 flex-col">
227 <span className="truncate">{thread.title || "Untitled thread"}</span>
228 <span className="truncate text-xs text-muted-foreground">
229 {projectNames.get(thread.projectId)}
230 </span>
231 </span>
232 {linked ? (
233 <>
234 <CheckIcon aria-hidden className="size-3.5" />
235 <span className="text-xs text-muted-foreground">Linked</span>
236 </>
237 ) : null}
238 </CommandItem>
239 );
240 })
241 )}
242 </CommandList>
243 </Command>
244 );
245}