1import type { JjChange, JjStack } from "@t3tools/contracts";
2import { LockIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
3import { useLayoutEffect, useRef, type MouseEvent } from "react";
4
5import { cn } from "~/lib/utils";
6import { ClaudeAI, OpenAI } from "../Icons";
7import { Button } from "../ui/button";
8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
9import { CommitTitle, parseCommitTitle, type CommitTitleTone } from "./commitTitle";
10import { changeTitle, type Selection } from "./jjReview.logic";
11
12const NODE_TONE: Record<CommitTitleTone | "none", string> = {
13 add: "text-success-foreground",
14 fix: "text-error-foreground",
15 other: "text-info-foreground",
16 wip: "text-warning-foreground",
17 none: "text-muted-foreground",
18};
19
20const changeTone = (change: JjChange) =>
21 parseCommitTitle(changeTitle(change)).prefix?.tone ?? "none";
22
23/** jj's own glyphs: `@` for the working copy, a diamond when immutable, a circle otherwise. */
24export function ChangeNode({ change, className }: { change: JjChange; className?: string }) {
25 if (change.workingCopy) {
26 return (
27 <span
28 className={cn(
29 "font-mono text-xs leading-none font-bold text-success-foreground",
30 className,
31 )}
32 >
33 @
34 </span>
35 );
36 }
37 const tone = change.conflict ? "text-error-foreground" : NODE_TONE[changeTone(change)];
38 return (
39 <svg viewBox="0 0 12 12" className={cn("size-3", tone, className)} aria-hidden>
40 {change.immutable ? (
41 <path d="M6 .8 11.2 6 6 11.2.8 6Z" fill="currentColor" />
42 ) : (
43 <circle
44 cx="6"
45 cy="6"
46 r="4.2"
47 fill="var(--background)"
48 stroke="currentColor"
49 strokeWidth="1.6"
50 />
51 )}
52 </svg>
53 );
54}
55
56function TrunkNode() {
57 return (
58 <svg viewBox="0 0 12 12" className="size-3 text-muted-foreground" aria-hidden>
59 <path
60 d="M6 1.2 10.8 6 6 10.8 1.2 6Z"
61 fill="var(--background)"
62 stroke="currentColor"
63 strokeWidth="1.4"
64 />
65 </svg>
66 );
67}
68
69function LineCounts({ additions, deletions }: { additions: number; deletions: number }) {
70 return (
71 <span className="font-mono text-3xs leading-3 tabular-nums">
72 <span className="text-success-foreground">+{additions}</span>{" "}
73 <span className="text-error-foreground">−{deletions}</span>
74 </span>
75 );
76}
77
78export function modelGlyph(model: string) {
79 if (model.startsWith("claude")) return <ClaudeAI className="size-3" aria-hidden />;
80 if (/^(gpt|chatgpt|o\d)/.test(model)) return <OpenAI className="size-3" aria-hidden />;
81 return null;
82}
83
84const inSelection = (selection: Selection, index: number) =>
85 index >= selection[0] && index <= selection[1];
86
87const extendsSelection = (event: MouseEvent) => event.shiftKey || event.metaKey || event.ctrlKey;
88
89/** One icon per change, oldest to newest, trunk first; line counts under each, the newest change named. */
90export function StackStrip(props: {
91 stack: JjStack;
92 changes: readonly JjChange[];
93 selection: Selection;
94 onSelect: (index: number, extend: boolean) => void;
95 onSelectAll: () => void;
96 onOpenRail: () => void;
97}) {
98 const { stack, changes, selection } = props;
99 const scroller = useRef<HTMLDivElement>(null);
100 const newestSelected = selection[0];
101 useLayoutEffect(() => {
102 if (changes.length === 0) return;
103 scroller.current
104 ?.querySelector(`[data-index="${newestSelected}"]`)
105 ?.scrollIntoView({ block: "nearest", inline: "nearest" });
106 }, [changes, newestSelected]);
107 const newest = changes[0];
108 const allSelected = selection[0] === 0 && selection[1] === changes.length - 1;
109
110 return (
111 <div className="flex shrink-0 items-center gap-1 border-b border-border/60 py-1.5 ps-2 pe-1.5">
112 <Button
113 size="icon-xs"
114 variant="ghost"
115 aria-label="Show stack and files"
116 aria-expanded={false}
117 onClick={props.onOpenRail}
118 >
119 <PanelLeftOpenIcon />
120 </Button>
121 <div
122 ref={scroller}
123 className="flex max-w-[70%] shrink-0 items-stretch overflow-x-auto [scrollbar-width:none]"
124 >
125 {stack.trunk ? (
126 <div
127 className="flex shrink-0 flex-col items-center gap-1 px-1.5 py-1"
128 aria-label={stack.trunk.name}
129 >
130 <TrunkNode />
131 <span className="text-3xs leading-3 whitespace-nowrap text-muted-foreground">
132 {stack.trunk.behind > 0 ? `${stack.trunk.behind} behind` : "trunk"}
133 </span>
134 </div>
135 ) : null}
136 {changes
137 .map((change, index) => ({ change, index }))
138 .toReversed()
139 .map(({ change, index }) => {
140 const selected = inSelection(selection, index);
141 return (
142 <Tooltip key={change.changeId}>
143 <TooltipTrigger
144 render={
145 <button
146 type="button"
147 data-index={index}
148 aria-label={changeTitle(change) || "(no description)"}
149 aria-pressed={selected}
150 onClick={(event) => props.onSelect(index, extendsSelection(event))}
151 className={cn(
152 "relative flex shrink-0 flex-col items-center gap-1 px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
153 "before:absolute before:top-[calc(0.25rem+5.5px)] before:right-1/2 before:-left-1/2 before:h-px before:bg-border",
154 selected ? "bg-primary/14" : "rounded-md hover:bg-accent",
155 selected && !inSelection(selection, index + 1) && "rounded-s-md",
156 selected && !inSelection(selection, index - 1) && "rounded-e-md",
157 )}
158 />
159 }
160 >
161 <ChangeNode change={change} className="relative" />
162 <LineCounts additions={change.additions} deletions={change.deletions} />
163 </TooltipTrigger>
164 <TooltipPopup side="bottom">
165 {changeTitle(change) ? (
166 <CommitTitle title={changeTitle(change)} />
167 ) : (
168 "(no description)"
169 )}
170 </TooltipPopup>
171 </Tooltip>
172 );
173 })}
174 </div>
175 {newest ? (
176 <span className="min-w-0 flex-1 truncate px-1.5 text-xs">
177 {changeTitle(newest) ? (
178 <CommitTitle title={changeTitle(newest)} />
179 ) : (
180 <span className="text-muted-foreground italic">(no description)</span>
181 )}
182 </span>
183 ) : null}
184 {changes.length > 1 ? (
185 <Button size="xs" variant={allSelected ? "secondary" : "ghost"} onClick={props.onSelectAll}>
186 All
187 </Button>
188 ) : null}
189 </div>
190 );
191}
192
193/** The vertical stack and the selection's files, shown when the panel has room. */
194export function StackRail(props: {
195 stack: JjStack;
196 changes: readonly JjChange[];
197 selection: Selection;
198 pendingCount: number;
199 files: ReadonlyArray<{ path: string; additions: number; deletions: number }>;
200 onSelect: (index: number, extend: boolean) => void;
201 onSelectAll: () => void;
202 onClose: () => void;
203 onOpenFile: (path: string) => void;
204}) {
205 const { stack, changes, selection } = props;
206 return (
207 <div className="flex w-56 shrink-0 flex-col border-e border-border/60 bg-muted/40">
208 <div className="flex h-9 shrink-0 items-center gap-1.5 ps-3 pe-1.5">
209 <span className="text-2xs font-semibold tracking-wide text-muted-foreground uppercase">
210 Stack
211 </span>
212 <span className="text-2xs text-muted-foreground">{changes.length}</span>
213 <span className="flex-1" />
214 <Button size="xs" variant="outline" onClick={props.onSelectAll}>
215 Review all
216 </Button>
217 <Button
218 size="icon-xs"
219 variant="ghost"
220 aria-label="Hide stack and files"
221 aria-expanded={true}
222 onClick={props.onClose}
223 >
224 <PanelLeftCloseIcon />
225 </Button>
226 </div>
227 <div className="relative shrink-0 pb-1 before:absolute before:top-3 before:bottom-4 before:left-[22px] before:w-px before:bg-border">
228 {changes.map((change, index) => {
229 const selected = inSelection(selection, index);
230 const model = change.trailers.find((t) => t.key === "Assisted-by")?.value;
231 const bookmark = change.bookmarks[0];
232 return (
233 <button
234 key={change.changeId}
235 type="button"
236 aria-pressed={selected}
237 onClick={(event) => props.onSelect(index, extendsSelection(event))}
238 className={cn(
239 "relative mx-1.5 grid w-[calc(100%-0.75rem)] grid-cols-[18px_minmax(0,1fr)] gap-1.5 px-2 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
240 selected ? "bg-primary/14" : "hover:bg-accent",
241 selected && !inSelection(selection, index - 1) && "rounded-t-md",
242 selected && !inSelection(selection, index + 1) && "rounded-b-md",
243 !selected && "rounded-md",
244 )}
245 >
246 <span className="flex justify-center pt-1">
247 <ChangeNode change={change} className="relative" />
248 </span>
249 <span className="min-w-0">
250 <span className="block truncate text-xs leading-4.5">
251 {changeTitle(change) ? (
252 <CommitTitle title={changeTitle(change)} />
253 ) : (
254 <span className="text-muted-foreground italic">(no description)</span>
255 )}
256 </span>
257 <span className="mt-0.5 flex items-center gap-1.5 overflow-hidden text-2xs whitespace-nowrap text-muted-foreground">
258 <span className="font-mono text-3xs">
259 <b className="font-bold text-foreground">{change.shortId[0]}</b>
260 {change.shortId.slice(1)}
261 </span>
262 <LineCounts additions={change.additions} deletions={change.deletions} />
263 {bookmark ? (
264 <span className="truncate text-success-foreground">{bookmark.name}</span>
265 ) : null}
266 {index < props.pendingCount ? (
267 <span className="text-warning-foreground">not pushed</span>
268 ) : null}
269 {change.immutable ? (
270 <LockIcon className="size-2.5 shrink-0" aria-label="Immutable" />
271 ) : null}
272 {model ? modelGlyph(model) : null}
273 </span>
274 </span>
275 </button>
276 );
277 })}
278 {stack.trunk ? (
279 <div className="mx-1.5 grid grid-cols-[18px_minmax(0,1fr)] items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground">
280 <span className="relative flex justify-center">
281 <TrunkNode />
282 </span>
283 <span className="truncate">
284 <span className="font-mono">{stack.trunk.name}</span>
285 {stack.trunk.behind > 0 ? ` · ${stack.trunk.behind} behind` : ""}
286 </span>
287 </div>
288 ) : null}
289 </div>
290 <div className="flex min-h-0 flex-1 flex-col border-t border-border/60">
291 <div className="flex h-8 shrink-0 items-center gap-1.5 px-3 text-2xs text-muted-foreground">
292 <span className="font-semibold tracking-wide uppercase">Files</span>
293 <span>{props.files.length}</span>
294 </div>
295 <div className="min-h-0 overflow-y-auto px-1.5 pb-2">
296 {props.files.map((file) => {
297 const slash = file.path.lastIndexOf("/");
298 return (
299 <button
300 key={file.path}
301 type="button"
302 aria-label={file.path}
303 onClick={() => props.onOpenFile(file.path)}
304 className="flex h-6 w-full items-center gap-1.5 rounded-md px-1.5 text-left text-xs hover:bg-accent"
305 >
306 <span className="min-w-0 flex-1 truncate">
307 <span className="text-muted-foreground">{file.path.slice(0, slash + 1)}</span>
308 {file.path.slice(slash + 1)}
309 </span>
310 <LineCounts additions={file.additions} deletions={file.deletions} />
311 </button>
312 );
313 })}
314 </div>
315 </div>
316 </div>
317 );
318}