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