1import { Link } from "@tanstack/react-router";
2import { formatDistanceToNow } from "date-fns";
3import { Tag, GitPullRequestClosed, Pencil, CircleDot } from "lucide-react";
4import { useState } from "react";
5
6import {
7 Status,
8 type BugDetailQuery,
9 useBugEditCommentMutation,
10 BugDetailDocument,
11} from "@/__generated__/graphql";
12import { Markdown } from "@/components/content/Markdown";
13import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
14import { Button } from "@/components/ui/button";
15import { Textarea } from "@/components/ui/textarea";
16import { useAuth } from "@/lib/auth";
17
18import { LabelBadge } from "./LabelBadge";
19
20type TimelineNode = NonNullable<
21 NonNullable<NonNullable<BugDetailQuery["repository"]>["bug"]>["timeline"]["nodes"][number]
22>;
23
24interface TimelineProps {
25 repo: string | null;
26 bugPrefix: string;
27 items: TimelineNode[];
28}
29
30// Ordered sequence of events on a bug: comments (create and add-comment) and
31// inline events (label changes, status changes, title edits). Comment items
32// support inline editing for the logged-in user.
33export function Timeline({ repo, bugPrefix, items }: TimelineProps) {
34 return (
35 <div className="space-y-4">
36 {items.map((item) => {
37 switch (item.__typename) {
38 case "BugCreateTimelineItem":
39 case "BugAddCommentTimelineItem":
40 return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
41 case "BugLabelChangeTimelineItem":
42 return <LabelChangeItem key={item.id} item={item} repo={repo} />;
43 case "BugSetStatusTimelineItem":
44 return <StatusChangeItem key={item.id} item={item} repo={repo} />;
45 case "BugSetTitleTimelineItem":
46 return <TitleChangeItem key={item.id} item={item} repo={repo} />;
47 default:
48 return null;
49 }
50 })}
51 </div>
52 );
53}
54
55// ββ Comment (create or add-comment) ββββββββββββββββββββββββββββββββββββββββββ
56
57type CommentItem = Extract<
58 TimelineNode,
59 { __typename: "BugCreateTimelineItem" | "BugAddCommentTimelineItem" }
60>;
61
62function CommentItem({
63 item,
64 bugPrefix,
65 repo,
66}: {
67 item: CommentItem;
68 bugPrefix: string;
69 repo: string | null;
70}) {
71 const { user } = useAuth();
72 const [editing, setEditing] = useState(false);
73 const [editValue, setEditValue] = useState(item.message ?? "");
74
75 const [editComment, { loading }] = useBugEditCommentMutation({
76 refetchQueries: [{ query: BugDetailDocument, variables: { prefix: bugPrefix } }],
77 });
78
79 function handleSave() {
80 if (editValue.trim() === (item.message ?? "").trim()) {
81 setEditing(false);
82 return;
83 }
84 void editComment({
85 variables: { input: { targetPrefix: item.id, message: editValue } },
86 }).then(() => setEditing(false));
87 }
88
89 function handleCancel() {
90 setEditValue(item.message ?? "");
91 setEditing(false);
92 }
93
94 const canEdit = user !== null && user.id === item.author.id;
95
96 return (
97 <div className="flex gap-3">
98 <Avatar className="mt-1 size-8 shrink-0">
99 <AvatarImage src={item.author.avatarUrl ?? undefined} alt={item.author.displayName} />
100 <AvatarFallback className="text-xs">
101 {item.author.displayName.slice(0, 2).toUpperCase()}
102 </AvatarFallback>
103 </Avatar>
104
105 <div className="border-border min-w-0 flex-1 rounded-md border">
106 <div className="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm">
107 <Link
108 to="/$repo/user/$id"
109 params={{ repo: repo!, id: item.author.humanId }}
110 search={{ status: "open" as const, after: "" }}
111 className="text-foreground font-medium hover:underline"
112 >
113 {item.author.displayName}
114 </Link>
115 <span className="text-muted-foreground">
116 {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
117 </span>
118 {item.edited && !editing && <span className="text-muted-foreground text-xs">edited</span>}
119 {canEdit && !editing && (
120 <button
121 onClick={() => setEditing(true)}
122 className="text-muted-foreground hover:bg-muted hover:text-foreground ml-auto rounded-sm px-1.5 py-0.5 text-xs"
123 >
124 Edit
125 </button>
126 )}
127 </div>
128
129 {editing ? (
130 <div className="space-y-2 p-3">
131 {/* Ctrl/Cmd+Enter saves; Escape cancels β standard editor shortcuts */}
132 <Textarea
133 value={editValue}
134 onChange={(e) => setEditValue(e.target.value)}
135 className="min-h-24 font-mono text-sm"
136 autoFocus
137 onKeyDown={(e) => {
138 if (e.key === "Escape") handleCancel();
139 if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
140 e.preventDefault();
141 handleSave();
142 }
143 }}
144 />
145 <div className="flex gap-2">
146 <Button size="sm" onClick={handleSave} disabled={loading}>
147 {loading ? "Savingβ¦" : "Save"}
148 </Button>
149 <Button size="sm" variant="ghost" onClick={handleCancel} disabled={loading}>
150 Cancel
151 </Button>
152 </div>
153 </div>
154 ) : (
155 <div className="px-4 py-3">
156 {item.message ? (
157 <Markdown content={item.message} />
158 ) : (
159 <p className="text-muted-foreground text-sm italic">No description provided.</p>
160 )}
161 </div>
162 )}
163 </div>
164 </div>
165 );
166}
167
168// ββ Inline events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
169
170type LabelChangeItem = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
171type StatusChangeItem = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
172type TitleChangeItem = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
173
174function EventRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
175 return (
176 <div className="text-muted-foreground flex items-center gap-3 pl-2 text-sm">
177 <span className="flex size-8 shrink-0 items-center justify-center">{icon}</span>
178 {children}
179 </div>
180 );
181}
182
183function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string | null }) {
184 return (
185 <EventRow icon={<Tag className="size-4" />}>
186 <span>
187 <Link
188 to="/$repo/user/$id"
189 params={{ repo: repo!, id: item.author.humanId }}
190 search={{ status: "open" as const, after: "" }}
191 className="text-foreground font-medium hover:underline"
192 >
193 {item.author.displayName}
194 </Link>{" "}
195 {item.added.length > 0 && (
196 <>
197 added{" "}
198 {item.added.map((l) => (
199 <LabelBadge key={l.name} name={l.name} color={l.color} />
200 ))}{" "}
201 </>
202 )}
203 {item.removed.length > 0 && (
204 <>
205 removed{" "}
206 {item.removed.map((l) => (
207 <LabelBadge key={l.name} name={l.name} color={l.color} />
208 ))}{" "}
209 </>
210 )}
211 {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
212 </span>
213 </EventRow>
214 );
215}
216
217function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string | null }) {
218 const isOpen = item.status === Status.Open;
219 return (
220 <EventRow
221 icon={
222 isOpen ? (
223 <CircleDot className="size-4 text-green-600 dark:text-green-400" />
224 ) : (
225 <GitPullRequestClosed className="size-4 text-purple-600 dark:text-purple-400" />
226 )
227 }
228 >
229 <span>
230 <Link
231 to="/$repo/user/$id"
232 params={{ repo: repo!, id: item.author.humanId }}
233 search={{ status: "open" as const, after: "" }}
234 className="text-foreground font-medium hover:underline"
235 >
236 {item.author.displayName}
237 </Link>{" "}
238 {isOpen ? "reopened" : "closed"} this{" "}
239 {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
240 </span>
241 </EventRow>
242 );
243}
244
245function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string | null }) {
246 return (
247 <EventRow icon={<Pencil className="size-4" />}>
248 <span>
249 <Link
250 to="/$repo/user/$id"
251 params={{ repo: repo!, id: item.author.humanId }}
252 search={{ status: "open" as const, after: "" }}
253 className="text-foreground font-medium hover:underline"
254 >
255 {item.author.displayName}
256 </Link>{" "}
257 changed the title from <span className="line-through">{item.was}</span> to{" "}
258 <span className="text-foreground font-medium">{item.title}</span>{" "}
259 {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
260 </span>
261 </EventRow>
262 );
263}