| 1 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 4 | import { CheckIcon, MessageSquareIcon } from "lucide-react"; |
| 5 | import { useState } from "react"; |
| 6 | import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; |
| 7 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 8 | |
| 9 | import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; |
| 10 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; |
| 11 | import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; |
| 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 13 | import { useEnvironmentQuery } from "~/state/query"; |
| 14 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 15 | import { openCommandPalette } from "~/commandPaletteBus"; |
| 16 | import { Button } from "../ui/button"; |
| 17 | import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; |
| 18 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 19 | import { MenuItem } from "../ui/menu"; |
| 20 | import { toastManager } from "../ui/toast"; |
| 21 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 22 | |
| 23 | interface 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. */ |
| 33 | export 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 | |
| 44 | function 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 | |
| 176 | function 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 | } |