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 className="text-foreground font-medium hover:underline"
111 >
112 {item.author.displayName}
113 </Link>
114 <span className="text-muted-foreground">
115 {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
116 </span>
117 {item.edited && !editing && <span className="text-muted-foreground text-xs">edited</span>}
118 {canEdit && !editing && (
119 <button
120 onClick={() => setEditing(true)}
121 className="text-muted-foreground hover:bg-muted hover:text-foreground ml-auto rounded-sm px-1.5 py-0.5 text-xs"
122 >
123 Edit
124 </button>
125 )}
126 </div>
127
128 {editing ? (
129 <div className="space-y-2 p-3">
130 {/* Ctrl/Cmd+Enter saves; Escape cancels β standard editor shortcuts */}
131 <Textarea
132 value={editValue}
133 onChange={(e) => setEditValue(e.target.value)}
134 className="min-h-24 font-mono text-sm"
135 autoFocus
136 onKeyDown={(e) => {
137 if (e.key === "Escape") handleCancel();
138 if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
139 e.preventDefault();
140 handleSave();
141 }
142 }}
143 />
144 <div className="flex gap-2">
145 <Button size="sm" onClick={handleSave} disabled={loading}>
146 {loading ? "Savingβ¦" : "Save"}
147 </Button>
148 <Button size="sm" variant="ghost" onClick={handleCancel} disabled={loading}>
149 Cancel
150 </Button>
151 </div>
152 </div>
153 ) : (
154 <div className="px-4 py-3">
155 {item.message ? (
156 <Markdown content={item.message} />
157 ) : (
158 <p className="text-muted-foreground text-sm italic">No description provided.</p>
159 )}
160 </div>
161 )}
162 </div>
163 </div>
164 );
165}
166
167// ββ Inline events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
168
169type LabelChangeItem = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
170type StatusChangeItem = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
171type TitleChangeItem = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
172
173function EventRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
174 return (
175 <div className="text-muted-foreground flex items-center gap-3 pl-2 text-sm">
176 <span className="flex size-8 shrink-0 items-center justify-center">{icon}</span>
177 {children}
178 </div>
179 );
180}
181
182function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string | null }) {
183 return (
184 <EventRow icon={<Tag className="size-4" />}>
185 <span>
186 <Link
187 to="/$repo/user/$id"
188 params={{ repo: repo!, id: item.author.humanId }}
189 className="text-foreground font-medium hover:underline"
190 >
191 {item.author.displayName}
192 </Link>{" "}
193 {item.added.length > 0 && (
194 <>
195 added{" "}
196 {item.added.map((l) => (
197 <LabelBadge key={l.name} name={l.name} color={l.color} />
198 ))}{" "}
199 </>
200 )}
201 {item.removed.length > 0 && (
202 <>
203 removed{" "}
204 {item.removed.map((l) => (
205 <LabelBadge key={l.name} name={l.name} color={l.color} />
206 ))}{" "}
207 </>
208 )}
209 {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
210 </span>
211 </EventRow>
212 );
213}
214
215function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string | null }) {
216 const isOpen = item.status === Status.Open;
217 return (
218 <EventRow
219 icon={
220 isOpen ? (
221 <CircleDot className="size-4 text-green-600 dark:text-green-400" />
222 ) : (
223 <GitPullRequestClosed className="size-4 text-purple-600 dark:text-purple-400" />
224 )
225 }
226 >
227 <span>
228 <Link
229 to="/$repo/user/$id"
230 params={{ repo: repo!, id: item.author.humanId }}
231 className="text-foreground font-medium hover:underline"
232 >
233 {item.author.displayName}
234 </Link>{" "}
235 {isOpen ? "reopened" : "closed"} this{" "}
236 {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
237 </span>
238 </EventRow>
239 );
240}
241
242function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string | null }) {
243 return (
244 <EventRow icon={<Pencil className="size-4" />}>
245 <span>
246 <Link
247 to="/$repo/user/$id"
248 params={{ repo: repo!, id: item.author.humanId }}
249 className="text-foreground font-medium hover:underline"
250 >
251 {item.author.displayName}
252 </Link>{" "}
253 changed the title from <span className="line-through">{item.was}</span> to{" "}
254 <span className="text-foreground font-medium">{item.title}</span>{" "}
255 {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
256 </span>
257 </EventRow>
258 );
259}