authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-16 08:58:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 12:58:34-03:00
loge8ca3a82806dff84438d5aba66b693468f7ca426
tree720c1d38dceaef9b1049052c32e220395fa42a52
parentf45c6b4a755905485431872f4a4188de3ed05974
signature Signed by PGP key B5690EEEBB952194

refactor(web): centralize pull request icon state presentation (#11144)


26 files changed, 628 insertions(+), 307 deletions(-)

apps/web/src/components/CommandPalette.tsx+3-3
......@@ -46,7 +46,6 @@ import {
4646 FileSearchIcon,
4747 FolderIcon,
4848 FolderPlusIcon,
49 GitPullRequestArrowIcon,
5049 LinkIcon,
5150 MessageSquareIcon,
5251 PaletteIcon,
......@@ -181,6 +180,7 @@ import {
181180 buildSidebarProjectSnapshots,
182181} from "../sidebarProjectGrouping";
183182import type { Project } from "../types";
183import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
184184
185185const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
186186
......@@ -1712,7 +1712,7 @@ function OpenCommandPaletteDialog(props: {
17121712 value: "action:link-pull-request",
17131713 searchTerms: ["link", "pull request", "pr", "attach", "stack"],
17141714 title: "Link pull request to thread",
1715 icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,
1715 icon: <PullRequestGlyph.link className={ITEM_ICON_CLASS} />,
17161716 run: async () => {
17171717 openLinkPullRequestDialog(threadRef);
17181718 },
......@@ -1724,7 +1724,7 @@ function OpenCommandPaletteDialog(props: {
17241724 searchTerms: ["pull requests", "linked", "stack", "prs"],
17251725 title: "Show linked pull requests",
17261726 disabled: visibleThreadPullRequests(activeThread.pullRequests).length === 0,
1727 icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,
1727 icon: <PullRequestGlyph.link className={ITEM_ICON_CLASS} />,
17281728 run: async () => {
17291729 useRightPanelStore.getState().open(threadRef, "pull-requests");
17301730 },
apps/web/src/components/LegacySidebar.tsx+2-2
......@@ -1,5 +1,4 @@
11import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { GitPullRequestIcon } from "lucide-react";
32import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
43import { Spinner } from "~/components/ui/spinner";
54import {
......@@ -214,6 +213,7 @@ import {
214213 type SidebarProjectGroupMember,
215214 type SidebarProjectSnapshot,
216215} from "../sidebarProjectGrouping";
216import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
217217const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
218218 updated_at: "Last user message",
219219 created_at: "Created at",
......@@ -757,7 +757,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
757757 className="text-muted-foreground"
758758 aria-label={`PR #${currentLinkedPr.number}, status pending`}
759759 >
760 <GitPullRequestIcon className="size-3" />
760 <PullRequestGlyph.pullRequest className="size-3" />
761761 </a>
762762 ) : null}
763763 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
apps/web/src/components/RightPanelTabs.tsx+10-10
......@@ -21,8 +21,6 @@ import {
2121 ChevronRight,
2222 FileDiff,
2323 Files,
24 GitPullRequest,
25 GitPullRequestArrow,
2624 Globe2,
2725 Plus,
2826 TerminalSquare,
......@@ -73,6 +71,7 @@ import { FaviconImage } from "./preview/PreviewFaviconIcon";
7371import { previewBridge } from "./preview/previewBridge";
7472import { PierreEntryIcon } from "./chat/PierreEntryIcon";
7573import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
74import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
7675
7776interface RightPanelTabsProps {
7877 mode: PreviewPanelMode;
......@@ -373,7 +372,7 @@ function RightPanelEmptyState(props: {
373372 },
374373 {
375374 label: "Pull request",
376 icon: GitPullRequest,
375 icon: PullRequestGlyph.pullRequest,
377376 shortcut: "P",
378377 available: props.pullRequestAvailable,
379378 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,
......@@ -382,7 +381,7 @@ function RightPanelEmptyState(props: {
382381 },
383382 {
384383 label: "Linked pull requests",
385 icon: GitPullRequestArrow,
384 icon: PullRequestGlyph.link,
386385 shortcut: "L",
387386 available: props.pullRequestsAvailable,
388387 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests,
......@@ -712,7 +711,7 @@ function SurfaceIcon({
712711 />
713712 );
714713 case "pull-requests":
715 return <GitPullRequestArrow className="size-3 shrink-0" />;
714 return <PullRequestGlyph.link className="size-3 shrink-0" />;
716715 case "agents":
717716 return <Bot className="size-3 shrink-0" />;
718717 case "device":
......@@ -801,8 +800,9 @@ function PullRequestSurfaceIcon({
801800 },
802801 }),
803802 ).data;
804 // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the
805 // full detail would flip an open tab to the conflict glyph the moment its read lands.
803 // The compact tab intentionally shows lifecycle and draft state only. Conflict warnings have
804 // their own presentation on surfaces that have mergeability, while this tab stays stable as
805 // detail data arrives.
806806 const status =
807807 linkedSnapshot !== null
808808 ? linkedSnapshot
......@@ -810,7 +810,7 @@ function PullRequestSurfaceIcon({
810810 ? (seed ?? null)
811811 : { state: detail.state, isDraft: detail.isDraft };
812812 if (status === null) {
813 return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />;
813 return <PullRequestGlyph.pullRequest className="size-3 shrink-0 text-muted-foreground" />;
814814 }
815815 const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft });
816816 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
......@@ -895,7 +895,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
895895 },
896896 {
897897 label: "Pull request",
898 icon: GitPullRequest,
898 icon: PullRequestGlyph.pullRequest,
899899 shortcut: "P",
900900 available: props.pullRequestAvailable,
901901 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
......@@ -903,7 +903,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
903903 },
904904 {
905905 label: "Linked pull requests",
906 icon: GitPullRequestArrow,
906 icon: PullRequestGlyph.link,
907907 shortcut: "L",
908908 available: props.pullRequestsAvailable,
909909 disabledReason: SURFACE_DISABLED_REASONS.pullRequests,
apps/web/src/components/ThreadStatusIndicators.test.ts+130-22
......@@ -1,25 +1,20 @@
11import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
3import {
4 GitMergeIcon,
5 GitPullRequestClosedIcon,
6 GitPullRequestDraftIcon,
7 GitPullRequestIcon,
8} from "lucide-react";
93
104import {
115 ChangeRequestStatusIcon,
126 prStatusIndicator,
13 settledPrHoverColorClass,
7 resolveThreadPullRequestBadgePresentation,
148} from "./ThreadStatusIndicators";
159import { newestPullRequestSummary } from "../state/pullRequests";
10import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
1611
1712describe("ChangeRequestStatusIcon", () => {
1813 it.each([
19 ["open", "open", false, GitPullRequestIcon],
20 ["draft", "open", true, GitPullRequestDraftIcon],
21 ["closed", "closed", false, GitPullRequestClosedIcon],
22 ["merged", "merged", false, GitMergeIcon],
14 ["open", "open", false, PullRequestGlyph.pullRequest],
15 ["draft", "open", true, PullRequestGlyph.draft],
16 ["closed", "closed", false, PullRequestGlyph.closed],
17 ["merged", "merged", false, PullRequestGlyph.merged],
2318 ] as const)("uses the %s pull request glyph", (_label, state, isDraft, expectedIcon) => {
2419 expect(ChangeRequestStatusIcon({ state, isDraft }).type).toBe(expectedIcon);
2520 });
......@@ -119,18 +114,131 @@ describe("prStatusIndicator", () => {
119114 });
120115});
121116
122describe("settledPrHoverColorClass", () => {
123 it.each([
124 ["open", "text-emerald-600"],
125 ["merged", "text-violet-600"],
126 ["closed", "text-red-600"],
127 ] as const)("restores the %s pull request color on row hover", (state, colorClass) => {
128 expect(settledPrHoverColorClass(state)).toContain(`group-hover/sidebar-row:${colorClass}`);
117describe("resolveThreadPullRequestBadgePresentation", () => {
118 const url = "https://github.com/pingdotgg/t3code/pull/42";
119
120 it("returns the pending pull-request badge when no snapshot is available", () => {
121 expect(
122 resolveThreadPullRequestBadgePresentation({
123 badge: null,
124 number: 42,
125 url,
126 status: null,
127 }),
128 ).toEqual({
129 Icon: PullRequestGlyph.pullRequest,
130 toneClassName: "text-muted-foreground",
131 label: "PR #42, status pending",
132 text: 42,
133 });
129134 });
130135
131 it("keeps draft pull requests gray on row hover", () => {
132 expect(settledPrHoverColorClass("open", true)).toContain(
133 "group-hover/sidebar-row:text-zinc-500",
134 );
136 it.each([
137 [
138 "open",
139 { state: "open", isDraft: false },
140 PullRequestGlyph.pullRequest,
141 "text-emerald-600 dark:text-emerald-300/90",
142 "PR #42 - Open: PR branch",
143 ],
144 [
145 "draft",
146 { state: "open", isDraft: true },
147 PullRequestGlyph.draft,
148 "text-zinc-500 dark:text-zinc-400/80",
149 "PR #42 - Draft: PR branch",
150 ],
151 [
152 "closed",
153 { state: "closed", isDraft: false },
154 PullRequestGlyph.closed,
155 "text-red-600 dark:text-red-300/90",
156 "PR #42 - Closed: PR branch",
157 ],
158 [
159 "merged",
160 { state: "merged", isDraft: false },
161 PullRequestGlyph.merged,
162 "text-violet-600 dark:text-violet-300/90",
163 "PR #42 - Merged: PR branch",
164 ],
165 ] as const)(
166 "keeps the %s state for one linked pull request",
167 (_state, prOverrides, expectedIcon, expectedToneClassName, expectedLabel) => {
168 const fixture = status().pr;
169 if (!fixture) throw new Error("Expected pull request fixture");
170 const prStatus = prStatusIndicator({ ...fixture, ...prOverrides }, undefined);
171 if (!prStatus) throw new Error("Expected pull request status");
172
173 expect(
174 resolveThreadPullRequestBadgePresentation({
175 badge: { kind: "pull-request", others: 0, state: "open" },
176 number: fixture.number,
177 url: fixture.url,
178 status: prStatus,
179 }),
180 ).toEqual({
181 Icon: expectedIcon,
182 toneClassName: expectedToneClassName,
183 label: expectedLabel,
184 text: fixture.number,
185 });
186 },
187 );
188
189 it.each([
190 ["open", "text-emerald-600 dark:text-emerald-300/90"],
191 ["draft", "text-zinc-500 dark:text-zinc-400/80"],
192 ["merged", "text-violet-600 dark:text-violet-300/90"],
193 ] as const)(
194 "uses a layers badge with the %s stack tone without a link identity",
195 (state, expectedToneClassName) => {
196 expect(
197 resolveThreadPullRequestBadgePresentation({
198 badge: { kind: "stack", layers: 3, state },
199 status: null,
200 }),
201 ).toEqual({
202 Icon: PullRequestGlyph.stack,
203 toneClassName: expectedToneClassName,
204 label: `Stack of 3 pull requests, ${state}`,
205 text: 3,
206 });
207 },
208 );
209
210 it.each([
211 ["open", PullRequestGlyph.pullRequest, "text-emerald-600 dark:text-emerald-300/90"],
212 ["draft", PullRequestGlyph.draft, "text-zinc-500 dark:text-zinc-400/80"],
213 ["merged", PullRequestGlyph.merged, "text-violet-600 dark:text-violet-300/90"],
214 ] as const)(
215 "draws the count of unrelated linked pull requests with their %s aggregate state",
216 (state, expectedIcon, expectedToneClassName) => {
217 const fixture = status().pr;
218 if (!fixture) throw new Error("Expected pull request fixture");
219 const closedStatus = prStatusIndicator(
220 { ...fixture, state: "closed", isDraft: false },
221 undefined,
222 );
223 if (!closedStatus) throw new Error("Expected pull request status");
224
225 expect(
226 resolveThreadPullRequestBadgePresentation({
227 badge: { kind: "pull-request", others: 2, state },
228 number: fixture.number,
229 url: fixture.url,
230 status: closedStatus,
231 }),
232 ).toEqual({
233 Icon: expectedIcon,
234 toneClassName: expectedToneClassName,
235 label: `PR #42 - Closed: PR branch, and 2 more linked; overall ${state}`,
236 text: "+3",
237 });
238 },
239 );
240
241 it("omits the control when neither a stack nor a linked identity can be shown", () => {
242 expect(resolveThreadPullRequestBadgePresentation({ badge: null, status: null })).toBeNull();
135243 });
136244});
apps/web/src/components/ThreadStatusIndicators.tsx+76-99
......@@ -14,7 +14,7 @@ import {
1414 visibleThreadPullRequests,
1515 type ThreadPullRequestBadge,
1616} from "@t3tools/shared/threadPullRequests";
17import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react";
17import { FolderGit2Icon, TerminalIcon } from "lucide-react";
1818import { useMemo, type MouseEvent } from "react";
1919import { buttonVariants, InlineButton } from "./ui/button";
2020import { cn } from "../lib/utils";
......@@ -31,11 +31,17 @@ import type { SidebarThreadSummary } from "../types";
3131import { formatWorktreePathForDisplay } from "../worktreeCleanup";
3232import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
3333import { pullRequestListLines } from "./pullRequest/pullRequestListLines";
34import {
35 PULL_REQUEST_STATE_PRESENTATION,
36 PullRequestGlyph,
37 type PullRequestGlyphIcon,
38} from "./pullRequest/pullRequestIcons";
3439import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
3540
3641export interface PrStatusIndicator {
3742 label: string;
3843 colorClass: string;
44 Icon: PullRequestGlyphIcon;
3945 tooltip: string;
4046 tooltipLead: string;
4147 tooltipTitle: string;
......@@ -125,16 +131,55 @@ export {
125131 type ThreadPullRequestBadge,
126132} from "@t3tools/shared/threadPullRequests";
127133
128/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */
129function ThreadPullRequestBadgeIcon({
130 icon,
131 className,
134export interface ThreadPullRequestBadgePresentation {
135 readonly Icon: PullRequestGlyphIcon;
136 readonly toneClassName: string;
137 readonly label: string;
138 readonly text: string | number;
139}
140
141/** Resolve the complete badge appearance before rendering it in the sidebar or composer. */
142export function resolveThreadPullRequestBadgePresentation({
143 badge,
144 number,
145 url,
146 status,
132147}: {
133 icon: "stack" | "pull-request";
134 className?: string | undefined;
135}) {
136 const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon;
137 return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;
148 readonly badge: ThreadPullRequestBadge | null;
149 readonly number?: number | undefined;
150 readonly url?: string | undefined;
151 readonly status: PrStatusIndicator | null;
152}): ThreadPullRequestBadgePresentation | null {
153 // The badge already folds every visible link into one state, draft included, so both the
154 // stack and the linked count index the shared table directly rather than the single-PR resolver.
155 if (badge?.kind === "stack") {
156 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
157 return {
158 Icon: PullRequestGlyph.stack,
159 toneClassName: aggregate.toneClassName,
160 label: `Stack of ${badge.layers} pull requests, ${aggregate.label.toLowerCase()}`,
161 text: badge.layers,
162 };
163 }
164 if (number === undefined || url === undefined) return null;
165
166 const tooltip = status?.tooltip ?? `PR #${number}, status pending`;
167 if (badge?.kind === "pull-request" && badge.others > 0) {
168 // Unrelated links fold into one state, so a count of merged PRs reads as merged.
169 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
170 return {
171 Icon: aggregate.Icon,
172 toneClassName: aggregate.toneClassName,
173 label: `${tooltip}, and ${badge.others} more linked; overall ${aggregate.label.toLowerCase()}`,
174 text: `+${badge.others + 1}`,
175 };
176 }
177 return {
178 Icon: status?.Icon ?? PullRequestGlyph.pullRequest,
179 toneClassName: status?.colorClass ?? "text-muted-foreground",
180 label: tooltip,
181 text: number,
182 };
138183}
139184
140185/** The complete linked-PR control shared by the sidebar and composer footer. */
......@@ -155,16 +200,9 @@ export function ThreadPullRequestBadgeControl({
155200 onOpenStack: () => void;
156201 onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void;
157202}) {
203 const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });
204 if (presentation === null) return null;
158205 const isStack = badge?.kind === "stack";
159 const linkedCount = badge?.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;
160 if (!isStack && (number === undefined || url === undefined)) return null;
161 const label = isStack
162 ? `Stack of ${badge.layers} pull requests, ${badge.state}`
163 : `${status?.tooltip ?? `PR #${number}, status pending`}${
164 badge?.kind === "pull-request" && badge.others > 0
165 ? `, and ${badge.others} more linked; overall ${badge.state}`
166 : ""
167 }`;
168206 const className = cn(
169207 variant === "ghost"
170208 ? buttonVariants({ variant: "ghost", size: "xs" })
......@@ -172,14 +210,12 @@ export function ThreadPullRequestBadgeControl({
172210 "text-xs tabular-nums",
173211 variant === "ghost" &&
174212 "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",
175 badge !== null && (isStack || linkedCount !== null)
176 ? PR_STATE_COLOR_CLASS[badge.state]
177 : (status?.colorClass ?? "text-muted-foreground"),
213 presentation.toneClassName,
178214 );
179215 const content = (
180216 <>
181 <ThreadPullRequestBadgeIcon icon={badge?.kind ?? "pull-request"} />
182 {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number}
217 <presentation.Icon aria-hidden className="size-3 shrink-0" />
218 {presentation.text}
183219 </>
184220 );
185221 return (
......@@ -189,7 +225,7 @@ export function ThreadPullRequestBadgeControl({
189225 isStack ? (
190226 <InlineButton
191227 className={className}
192 aria-label={label}
228 aria-label={presentation.label}
193229 onPointerDown={(event) => event.stopPropagation()}
194230 onClick={(event) => {
195231 event.preventDefault();
......@@ -203,7 +239,7 @@ export function ThreadPullRequestBadgeControl({
203239 target="_blank"
204240 rel="noopener noreferrer"
205241 className={className}
206 aria-label={label}
242 aria-label={presentation.label}
207243 onPointerDown={(event) => event.stopPropagation()}
208244 onClick={onOpenPullRequest}
209245 />
......@@ -212,7 +248,7 @@ export function ThreadPullRequestBadgeControl({
212248 >
213249 {content}
214250 </TooltipTrigger>
215 <TooltipPopup side="top">{label}</TooltipPopup>
251 <TooltipPopup side="top">{presentation.label}</TooltipPopup>
216252 </Tooltip>
217253 );
218254}
......@@ -254,7 +290,7 @@ export function ThreadPullRequestsMiniList({
254290 className={cn("size-3 shrink-0", presentation.toneClassName)}
255291 />
256292 ) : (
257 <GitPullRequestArrowIcon
293 <PullRequestGlyph.pullRequest
258294 aria-hidden
259295 className="size-3 shrink-0 stroke-muted-foreground"
260296 />
......@@ -275,83 +311,24 @@ export function ThreadPullRequestsMiniList({
275311 );
276312}
277313
278/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */
279const PR_STATE_COLOR_CLASS: Record<ThreadPullRequestBadge["state"], string> = {
280 open: "text-emerald-600 dark:text-emerald-300/90",
281 merged: "text-violet-600 dark:text-violet-300/90",
282 closed: "text-red-600 dark:text-red-300/90",
283 draft: "text-zinc-500 dark:text-zinc-400/80",
284};
285
286export function settledPrHoverColorClass(
287 state: NonNullable<ThreadPr>["state"],
288 isDraft = false,
289): string {
290 switch (state) {
291 case "open":
292 if (isDraft) {
293 return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80";
294 }
295 return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90";
296 case "merged":
297 return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90";
298 case "closed":
299 return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90";
300 }
301}
302
303314export function prStatusIndicator(
304315 pr: ThreadPr,
305316 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
306317): PrStatusIndicator | null {
307 function formatPrState(pr: NonNullable<ThreadPr>): string {
308 if (pr.state === "open" && pr.isDraft === true) return "Draft";
309 return pr.state.charAt(0).toUpperCase() + pr.state.slice(1);
310 }
311
312 function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string {
313 return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`;
314 }
315318 if (!pr) return null;
316319 const presentation = resolveChangeRequestPresentation(provider);
320 const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true });
317321
318 const tooltipLead = formatPrStatusLead(pr, presentation.shortName);
319 const tooltip = `${tooltipLead}: ${pr.title}`;
320
321 if (pr.state === "open") {
322 const isDraft = pr.isDraft === true;
323 return {
324 label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`,
325 colorClass: isDraft
326 ? "text-zinc-500 dark:text-zinc-400/80"
327 : "text-emerald-600 dark:text-emerald-300/90",
328 tooltip,
329 tooltipLead,
330 tooltipTitle: pr.title,
331 url: pr.url,
332 };
333 }
334 if (pr.state === "closed") {
335 return {
336 label: `${presentation.shortName} closed`,
337 colorClass: "text-red-600 dark:text-red-300/90",
338 tooltip,
339 tooltipLead,
340 tooltipTitle: pr.title,
341 url: pr.url,
342 };
343 }
344 if (pr.state === "merged") {
345 return {
346 label: `${presentation.shortName} merged`,
347 colorClass: "text-violet-600 dark:text-violet-300/90",
348 tooltip,
349 tooltipLead,
350 tooltipTitle: pr.title,
351 url: pr.url,
352 };
353 }
354 return null;
322 const tooltipLead = `${presentation.shortName} #${pr.number} - ${state.label}`;
323 return {
324 label: `${presentation.shortName} ${state.label.toLowerCase()}`,
325 colorClass: state.toneClassName,
326 Icon: state.Icon,
327 tooltip: `${tooltipLead}: ${pr.title}`,
328 tooltipLead,
329 tooltipTitle: pr.title,
330 url: pr.url,
331 };
355332}
356333
357334export function ChangeRequestStatusIcon({
......@@ -529,7 +506,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
529506 </Tooltip>
530507 ) : null}
531508 {pendingLink ? (
532 <GitPullRequestArrowIcon
509 <PullRequestGlyph.pullRequest
533510 className="size-3 text-muted-foreground"
534511 aria-label={`PR #${pendingLink.number}, status pending`}
535512 />
apps/web/src/components/chat/MessagesTimeline.tsx+3-3
......@@ -112,7 +112,6 @@ import {
112112 CircleAlertIcon,
113113 DownloadIcon,
114114 EyeIcon,
115 GitPullRequestIcon,
116115 GlobeIcon,
117116 HammerIcon,
118117 MessageCircleIcon,
......@@ -250,6 +249,7 @@ import {
250249 formatReviewCommentFence,
251250 type ReviewCommentContext,
252251} from "../../reviewCommentContext";
252import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
253253
254254// ---------------------------------------------------------------------------
255255// Context — shared state consumed by every row component via Context.
......@@ -3326,7 +3326,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
33263326 <UserMessageContextChip
33273327 icon={
33283328 isPullRequest ? (
3329 <GitPullRequestIcon
3329 <PullRequestGlyph.pullRequest
33303330 className={cn(
33313331 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
33323332 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],
......@@ -3877,7 +3877,7 @@ function ToolActivityImageIcon(props: {
38773877function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
38783878 switch (name) {
38793879 case "pull-request":
3880 return <GitPullRequestIcon className={className} aria-hidden />;
3880 return <PullRequestGlyph.pullRequest className={className} aria-hidden />;
38813881 case "bot":
38823882 return <BotIcon className={className} aria-hidden />;
38833883 case "brain":
apps/web/src/components/composerContextPresentation.tsx+3-2
......@@ -3,7 +3,7 @@ import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview";
33import type { PreviewAnnotationPayload } from "@t3tools/contracts";
44import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
55import { videoMimeType } from "@t3tools/shared/video";
6import { GitPullRequestIcon, MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
6import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
77import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";
88
99import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
......@@ -14,6 +14,7 @@ import {
1414 type AttachmentUploadState,
1515} from "~/lib/attachmentUploadState";
1616import { cn } from "~/lib/utils";
17import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
1718import {
1819 fileContextReference,
1920 imageContextReference,
......@@ -392,7 +393,7 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
392393 <ContextChip
393394 icon={
394395 isPullRequest ? (
395 <GitPullRequestIcon
396 <PullRequestGlyph.pullRequest
396397 className={cn(
397398 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
398399 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],
apps/web/src/components/contextChipParts.tsx+5-2
......@@ -1,5 +1,5 @@
11import type { PullRequestContextMetadata } from "@t3tools/contracts";
2import { CircleDashedIcon, FilmIcon, GitPullRequestIcon, ImageIcon } from "lucide-react";
2import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";
33import {
44 useState,
55 type ComponentProps,
......@@ -9,6 +9,7 @@ import {
99} from "react";
1010
1111import { cn } from "~/lib/utils";
12import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
1213import { PierreEntryIcon } from "./chat/PierreEntryIcon";
1314import {
1415 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
......@@ -134,7 +135,9 @@ export function PullRequestChip(props: {
134135 data-markdown-copy={props.copyMarkdown}
135136 onClick={(event) => props.onOpen(event, props.metadata.url)}
136137 >
137 <GitPullRequestIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} />
138 <PullRequestGlyph.pullRequest
139 className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
140 />
138141 <span className={props.labelClassName}>{props.label}</span>
139142 </Button>
140143 }
apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx+4-9
......@@ -1,11 +1,5 @@
11import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
2import {
3 GitPullRequestClosedIcon,
4 MessageSquareIcon,
5 RotateCcwIcon,
6 SendIcon,
7 XIcon,
8} from "lucide-react";
2import { MessageSquareIcon, SendIcon, XIcon } from "lucide-react";
93import { useRef, useState } from "react";
104
115import { useAtomCommand } from "~/state/use-atom-command";
......@@ -15,6 +9,7 @@ import { Button } from "../ui/button";
159import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
1610import { Textarea } from "../ui/textarea";
1711import { toastManager } from "../ui/toast";
12import { PullRequestGlyph } from "./pullRequestIcons";
1813
1914export function PullRequestCommentComposer({
2015 environmentId,
......@@ -146,9 +141,9 @@ export function PullRequestCommentComposer({
146141 onClick={() => void submit(followUpAction)}
147142 >
148143 {followUpAction === "close" ? (
149 <GitPullRequestClosedIcon className="size-3.5" />
144 <PullRequestGlyph.closed className="size-3.5" />
150145 ) : (
151 <RotateCcwIcon className="size-3.5" />
146 <PullRequestGlyph.reopen className="size-3.5" />
152147 )}
153148 {submitting === followUpAction
154149 ? followUpAction === "close"
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+18-22
......@@ -29,12 +29,7 @@ import {
2929 FolderGit2Icon,
3030 GitBranchIcon,
3131 GitCommitHorizontalIcon,
32 GitMergeIcon,
33 GitPullRequestClosedIcon,
34 GitPullRequestDraftIcon,
35 GitPullRequestIcon,
3632 HammerIcon,
37 LayersIcon,
3833 MessageCircleQuestionIcon,
3934 MessageSquareIcon,
4035 LinkIcon,
......@@ -176,6 +171,7 @@ import {
176171 resolvePullRequestState,
177172 summarizePullRequestChecks,
178173} from "./pullRequestPresentation";
174import { PullRequestGlyph } from "./pullRequestIcons";
179175
180176type DetailTab = "summary" | "timeline" | "code";
181177
......@@ -455,7 +451,7 @@ function PullRequestBaseFreshnessWarning({
455451 disabled={pending}
456452 onClick={() => onUpdate(method)}
457453 >
458 <GitMergeIcon aria-hidden className="size-3" />
454 <PullRequestGlyph.merged aria-hidden className="size-3" />
459455 {method === "rebase" ? "Update with rebase" : "Update branch"}
460456 </Button>
461457 ))}
......@@ -1803,7 +1799,7 @@ export function PullRequestDetailPanel({
18031799 role="img"
18041800 aria-label={armedAutoMergeLabel}
18051801 >
1806 <GitMergeIcon aria-hidden className="size-3.5" />
1802 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
18071803 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>
18081804 </Badge>
18091805 }
......@@ -1828,7 +1824,7 @@ export function PullRequestDetailPanel({
18281824 handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"
18291825 }
18301826 >
1831 <TriangleAlertIcon aria-hidden className="size-3.5" />
1827 <PullRequestGlyph.conflicting aria-hidden className="size-3.5" />
18321828 <span className="@max-[30rem]/pr-header:hidden">
18331829 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}
18341830 </span>
......@@ -1852,7 +1848,7 @@ export function PullRequestDetailPanel({
18521848 onClick={() => void perform("ready")}
18531849 aria-label="Ready for review"
18541850 >
1855 <GitPullRequestIcon aria-hidden className="size-3.5" />
1851 <PullRequestGlyph.pullRequest aria-hidden className="size-3.5" />
18561852 <span className="@max-[30rem]/pr-header:hidden">Ready for review</span>
18571853 </Button>
18581854 </span>
......@@ -1878,7 +1874,7 @@ export function PullRequestDetailPanel({
18781874 : pendingAutoMergeLabel
18791875 }
18801876 >
1881 <GitMergeIcon aria-hidden className="size-3.5" />
1877 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
18821878 <span className="@max-[30rem]/pr-header:hidden">
18831879 {pendingAction === "enable-auto-merge"
18841880 ? "Enabling..."
......@@ -1902,7 +1898,7 @@ export function PullRequestDetailPanel({
19021898 role="img"
19031899 aria-label={armedAutoMergeLabel}
19041900 >
1905 <GitMergeIcon aria-hidden className="size-3.5" />
1901 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
19061902 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>
19071903 </Badge>
19081904 }
......@@ -1926,7 +1922,7 @@ export function PullRequestDetailPanel({
19261922 pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel
19271923 }
19281924 >
1929 <GitMergeIcon aria-hidden className="size-3.5" />
1925 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
19301926 <span className="@max-[30rem]/pr-header:hidden">
19311927 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
19321928 </span>
......@@ -2031,9 +2027,9 @@ export function PullRequestDetailPanel({
20312027 onClick={() => void perform(detail.isDraft ? "ready" : "draft")}
20322028 >
20332029 {detail.isDraft ? (
2034 <GitPullRequestIcon className="size-3.5" />
2030 <PullRequestGlyph.pullRequest className="size-3.5" />
20352031 ) : (
2036 <GitPullRequestDraftIcon className="size-3.5" />
2032 <PullRequestGlyph.draft className="size-3.5" />
20372033 )}
20382034 {detail.isDraft ? "Ready for review" : "Convert to draft"}
20392035 </MenuItem>
......@@ -2043,7 +2039,7 @@ export function PullRequestDetailPanel({
20432039 disabled={actionPending}
20442040 onClick={() => setConfirmation({ open: true, action: "merge" })}
20452041 >
2046 <GitMergeIcon className="size-3.5" />
2042 <PullRequestGlyph.merged className="size-3.5" />
20472043 Merge now
20482044 </MenuItem>
20492045 ) : null}
......@@ -2055,7 +2051,7 @@ export function PullRequestDetailPanel({
20552051 disabled={actionPending}
20562052 onClick={() => void perform("disable-auto-merge")}
20572053 >
2058 <GitMergeIcon className="size-3.5" />
2054 <PullRequestGlyph.merged className="size-3.5" />
20592055 Disable auto-merge
20602056 </MenuItem>
20612057 ) : showsAutoMerge ? (
......@@ -2065,7 +2061,7 @@ export function PullRequestDetailPanel({
20652061 setConfirmation({ open: true, action: "enable-auto-merge" })
20662062 }
20672063 >
2068 <GitMergeIcon className="size-3.5" />
2064 <PullRequestGlyph.merged className="size-3.5" />
20692065 Enable auto-merge
20702066 </MenuItem>
20712067 ) : null}
......@@ -2100,7 +2096,7 @@ export function PullRequestDetailPanel({
21002096 {/* The radio item lays its children out as one block, so the
21012097 icon and the label need their own row to share a line. */}
21022098 <span className="flex min-w-0 items-center gap-2">
2103 <GitMergeIcon className="size-3.5" />
2099 <PullRequestGlyph.merged className="size-3.5" />
21042100 <span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span>
21052101 </span>
21062102 </MenuRadioItem>
......@@ -2143,7 +2139,7 @@ export function PullRequestDetailPanel({
21432139 disabled={actionPending}
21442140 onClick={() => setConfirmation({ open: true, action: "close" })}
21452141 >
2146 <GitPullRequestClosedIcon className="size-3.5" />
2142 <PullRequestGlyph.closed className="size-3.5" />
21472143 Close pull request
21482144 </MenuItem>
21492145 </>
......@@ -2151,7 +2147,7 @@ export function PullRequestDetailPanel({
21512147 <>
21522148 <MenuSeparator />
21532149 <MenuItem disabled={actionPending} onClick={() => void perform("reopen")}>
2154 <GitPullRequestIcon className="size-3.5" />
2150 <PullRequestGlyph.reopen className="size-3.5" />
21552151 Reopen pull request
21562152 </MenuItem>
21572153 </>
......@@ -2220,7 +2216,7 @@ export function PullRequestDetailPanel({
22202216 render={
22212217 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
22222218 {isStackedPullRequest ? (
2223 <LayersIcon
2219 <PullRequestGlyph.stack
22242220 aria-label="Stacked pull request"
22252221 className="size-3 shrink-0"
22262222 />
......@@ -2404,7 +2400,7 @@ export function PullRequestDetailPanel({
24042400 render={
24052401 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
24062402 {isStackedPullRequest ? (
2407 <LayersIcon
2403 <PullRequestGlyph.stack
24082404 aria-label="Stacked pull request"
24092405 className="size-3 shrink-0"
24102406 />
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+2-2
......@@ -15,7 +15,6 @@ import {
1515 CircleXIcon,
1616 EyeOffIcon,
1717 FolderGit2Icon,
18 GitPullRequestDraftIcon,
1918 LayersIcon,
2019 ListFilterIcon,
2120 SearchIcon,
......@@ -51,6 +50,7 @@ import {
5150 type PullRequestLabelFacet,
5251} from "./pullRequestList.logic";
5352import { PullRequestActorAvatar } from "./pullRequestPresentation";
53import { PullRequestGlyph } from "./pullRequestIcons";
5454
5555export interface PullRequestFilterOption<Value extends string> {
5656 readonly value: Value;
......@@ -143,7 +143,7 @@ export const pullRequestProjectKey = (project: {
143143
144144const DRAFT_OPTIONS = [
145145 { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon },
146 { value: "only", label: "Drafts only", Icon: GitPullRequestDraftIcon },
146 { value: "only", label: "Drafts only", Icon: PullRequestGlyph.draft },
147147 { value: "hide", label: "Hide drafts", Icon: EyeOffIcon },
148148] as const satisfies ReadonlyArray<PullRequestFilterOption<string>>;
149149
apps/web/src/components/pullRequest/PullRequestRow.tsx+18-6
......@@ -12,6 +12,7 @@ import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullR
1212import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
1313import {
1414 PullRequestActorLabel,
15 PullRequestConflictGlyph,
1516 PullRequestDiffStat,
1617 PullRequestMetaLine,
1718 PullRequestApprovalGlyph,
......@@ -112,12 +113,23 @@ function PullRequestRowImpl({
112113 selected ? "bg-accent" : "hover:bg-accent/60",
113114 )}
114115 >
115 <PullRequestStateGlyph
116 state={entry.state}
117 isDraft={entry.isDraft}
118 mergeability={entry.mergeability}
119 baseBranch={entry.baseBranch}
120 />
116 {/* The conflict warning rides the corner of the lifecycle glyph, over the arrow's
117 merge circle, so the leading slot stays one icon wide and titles line up whether or
118 not a row is blocked. The background fill cuts it out of the glyph beneath. */}
119 <span className="relative inline-flex shrink-0">
120 <PullRequestStateGlyph state={entry.state} isDraft={entry.isDraft} />
121 {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the
122 badge's size and anchors the popup to it. */}
123 <span className="absolute -right-1 -bottom-1 inline-flex">
124 <PullRequestConflictGlyph
125 state={entry.state}
126 isDraft={entry.isDraft}
127 mergeability={entry.mergeability}
128 baseBranch={entry.baseBranch}
129 className="size-3 fill-background [stroke-width:2.5]"
130 />
131 </span>
132 </span>
121133 <span className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1.5">
122134 <span className="col-start-1 row-start-1 block truncate text-sm font-medium text-foreground">
123135 {entry.title}
apps/web/src/components/pullRequest/PullRequestStackMenu.tsx+6-4
......@@ -6,7 +6,7 @@ import type {
66 PullRequestMergeMethod,
77} from "@t3tools/contracts";
88import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
9import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
1010import { useState } from "react";
1111import { useAtomCommand } from "~/state/use-atom-command";
1212import { pullRequestEnvironment } from "~/state/pullRequests";
......@@ -25,6 +25,7 @@ import { toastManager } from "../ui/toast";
2525import { PullRequestStackLayers } from "./PullRequestStackLayers";
2626import { PullRequestStackHeader } from "./PullRequestStackHeader";
2727import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
28import { PullRequestGlyph } from "./pullRequestIcons";
2829
2930export function PullRequestStackMenu({
3031 stack,
......@@ -133,7 +134,8 @@ export function PullRequestStackMenu({
133134 />
134135 }
135136 >
136 <LayersIcon aria-hidden className="size-3.5" /> {position}/{stack.layers.length}
137 <PullRequestGlyph.stack aria-hidden className="size-3.5" /> {position}/
138 {stack.layers.length}
137139 {onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null}
138140 </MenuTrigger>
139141 }
......@@ -166,7 +168,7 @@ export function PullRequestStackMenu({
166168 <MenuSeparator />
167169 {canMerge ? (
168170 <MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}>
169 <GitMergeIcon aria-hidden />
171 <PullRequestGlyph.merged aria-hidden />
170172 Merge stack ({mergeLayers.length})
171173 </MenuItem>
172174 ) : null}
......@@ -199,7 +201,7 @@ export function PullRequestStackMenu({
199201 disabled={mergeDisabled}
200202 onClick={() => setConfirmation("merge")}
201203 >
202 <GitMergeIcon aria-hidden className="size-3.5" />
204 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
203205 Merge stack
204206 </Button>
205207 </span>
apps/web/src/components/pullRequest/PullRequestStackPopover.tsx+2-2
......@@ -1,11 +1,11 @@
11import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
22import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts";
3import { LayersIcon } from "lucide-react";
43import { useState } from "react";
54import { usePullRequestStack } from "~/state/usePullRequestStack";
65import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu";
76import { PullRequestStackLayers } from "./PullRequestStackLayers";
87import { PullRequestStackHeader } from "./PullRequestStackHeader";
8import { PullRequestGlyph } from "./pullRequestIcons";
99
1010/** Mounted only while the menu is open, so list rows do not each fetch a stack. */
1111function StackBody({
......@@ -74,7 +74,7 @@ export function PullRequestStackPopover({
7474 onClick={(event) => event.stopPropagation()}
7575 onKeyDown={(event) => event.stopPropagation()}
7676 >
77 <LayersIcon aria-hidden className="size-3" />
77 <PullRequestGlyph.stack aria-hidden className="size-3" />
7878 {membership.position}/{membership.size}
7979 </MenuTrigger>
8080 }
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx+4-3
......@@ -1,7 +1,7 @@
11import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
22import { scopeThreadRef } from "@t3tools/client-runtime/environment";
33import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
4import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react";
4import { CheckIcon, MessageSquareIcon } from "lucide-react";
55import { useState } from "react";
66import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
77import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
......@@ -18,6 +18,7 @@ import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";
1818import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
1919import { MenuItem } from "../ui/menu";
2020import { toastManager } from "../ui/toast";
21import { PullRequestGlyph } from "./pullRequestIcons";
2122
2223interface PullRequestThreadLinksProps {
2324 environmentId: EnvironmentId;
......@@ -144,9 +145,9 @@ function EnabledPullRequestThreadLinks({
144145 }}
145146 >
146147 {linkedHere ? (
147 <UnlinkIcon aria-hidden className="size-3.5" />
148 <PullRequestGlyph.unlink aria-hidden className="size-3.5" />
148149 ) : (
149 <LinkIcon aria-hidden className="size-3.5" />
150 <PullRequestGlyph.link aria-hidden className="size-3.5" />
150151 )}
151152 {linkedHere
152153 ? "Unlink from this thread"
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+5-7
......@@ -11,9 +11,6 @@ import {
1111 ExternalLinkIcon,
1212 FileCode2Icon,
1313 GitCommitHorizontalIcon,
14 GitMergeIcon,
15 GitPullRequestClosedIcon,
16 GitPullRequestIcon,
1714 MessageSquareIcon,
1815 PencilIcon,
1916} from "lucide-react";
......@@ -51,6 +48,7 @@ import {
5148 pullRequestReviewOutcomeStaleLabel,
5249 pullRequestReviewOutcomeToneClassName,
5350} from "./pullRequestPresentation";
51import { PullRequestGlyph } from "./pullRequestIcons";
5452
5553/** What every comment on the timeline needs to react; only the subject differs between them. */
5654interface ReactionSurface {
......@@ -419,16 +417,16 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {
419417 const presentation =
420418 event.kind === "opened"
421419 ? {
422 icon: <GitPullRequestIcon className="size-3.5" />,
420 icon: <PullRequestGlyph.pullRequest className="size-3.5" />,
423421 label: "Pull request opened",
424422 }
425423 : event.kind === "merged"
426424 ? {
427 icon: <GitMergeIcon className="size-3.5" />,
425 icon: <PullRequestGlyph.merged className="size-3.5" />,
428426 label: "Pull request merged",
429427 }
430428 : {
431 icon: <GitPullRequestClosedIcon className="size-3.5" />,
429 icon: <PullRequestGlyph.closed className="size-3.5" />,
432430 label: "Pull request closed",
433431 };
434432
......@@ -629,7 +627,7 @@ export function PullRequestTimelineTab({
629627
630628 {events.length === 0 ? (
631629 <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
632 <GitPullRequestIcon className="mb-2 size-5" />
630 <PullRequestGlyph.pullRequest className="mb-2 size-5" />
633631 <p className="text-xs">No activity yet.</p>
634632 </div>
635633 ) : null}
apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx+3-2
......@@ -1,5 +1,5 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react";
2import { ExternalLinkIcon } from "lucide-react";
33
44import { Button } from "../ui/button";
55import {
......@@ -10,6 +10,7 @@ import {
1010 EmptyMedia,
1111 EmptyTitle,
1212} from "../ui/empty";
13import { PullRequestGlyph } from "./pullRequestIcons";
1314
1415export function PullRequestsUnavailableState({
1516 title = "Could not load pull requests",
......@@ -27,7 +28,7 @@ export function PullRequestsUnavailableState({
2728 return (
2829 <Empty className="min-h-0 justify-center-safe overflow-y-auto px-4 py-16 md:px-4 [&>*]:shrink-0">
2930 <EmptyMedia variant="icon">
30 <GitPullRequestIcon />
31 <PullRequestGlyph.pullRequest />
3132 </EmptyMedia>
3233 <EmptyHeader>
3334 <EmptyTitle>{title}</EmptyTitle>
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+24-20
......@@ -3,13 +3,7 @@ import {
33 resolveThreadPullRequestChains,
44 visibleThreadPullRequests,
55} from "@t3tools/shared/threadPullRequests";
6import {
7 GitPullRequestArrow,
8 LayersIcon,
9 LinkIcon,
10 MoreHorizontalIcon,
11 PlusIcon,
12} from "lucide-react";
6import { ArrowUpRightIcon, LinkIcon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
137import { useCallback, useMemo } from "react";
148
159import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
......@@ -28,11 +22,13 @@ import { openLinkPullRequestDialog } from "./LinkPullRequestDialog";
2822import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines";
2923import {
3024 PullRequestActorAvatar,
25 PullRequestConflictGlyph,
3126 PullRequestDiffStat,
3227 PullRequestApprovalGlyph,
3328 PullRequestStateGlyph,
3429 pullRequestChecksStatePresentation,
3530} from "./pullRequestPresentation";
31import { PullRequestGlyph } from "./pullRequestIcons";
3632
3733const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
3834 manual: "Linked by you",
......@@ -84,12 +80,20 @@ function LinkRow({
8480 >
8581 {depth > 0 ? <span aria-hidden className="-ml-2 h-6 w-px shrink-0 bg-border/70" /> : null}
8682 {snapshot === null ? (
87 <GitPullRequestArrow
83 <PullRequestGlyph.pullRequest
8884 aria-label="Waiting for host state"
8985 className="size-4 shrink-0 text-muted-foreground"
9086 />
9187 ) : (
92 <PullRequestStateGlyph state={snapshot.state} isDraft={snapshot.isDraft} />
88 <span className="flex shrink-0 items-center gap-1">
89 <PullRequestStateGlyph state={snapshot.state} isDraft={snapshot.isDraft} />
90 <PullRequestConflictGlyph
91 state={snapshot.state}
92 isDraft={snapshot.isDraft}
93 baseBranch={snapshot.baseBranch}
94 {...(snapshot.mergeability ? { mergeability: snapshot.mergeability } : {})}
95 />
96 </span>
9397 )}
9498 <a
9599 href={link.url}
......@@ -125,9 +129,6 @@ function LinkRow({
125129 <span className="text-amber-600/90 dark:text-amber-400/80">Changes requested</span>
126130 )
127131 ) : null}
128 {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? (
129 <span className="text-destructive">Conflicts</span>
130 ) : null}
131132 {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}
132133 <PullRequestDiffStat
133134 additions={snapshot?.additions ?? 0}
......@@ -144,11 +145,7 @@ function LinkRow({
144145 <span className="inline-flex shrink-0 items-center gap-0.5 text-foreground/70" />
145146 }
146147 >
147 {stack.kind === "native" ? (
148 <LayersIcon aria-hidden className="size-3" />
149 ) : (
150 <GitPullRequestArrow aria-hidden className="size-3" />
151 )}
148 <PullRequestGlyph.stack aria-hidden className="size-3" />
152149 {stack.size}
153150 </TooltipTrigger>
154151 <TooltipPopup>
......@@ -190,9 +187,16 @@ function LinkRow({
190187 }
191188 />
192189 <MenuPopup align="end" side="bottom">
193 <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>Copy link</MenuItem>
194 <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>Open</MenuItem>
190 <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>
191 <LinkIcon className="size-3.5" />
192 Copy link
193 </MenuItem>
194 <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>
195 <ArrowUpRightIcon className="size-3.5" />
196 Open
197 </MenuItem>
195198 <MenuItem onClick={() => onUnlink(link)}>
199 <PullRequestGlyph.unlink className="size-3.5" />
196200 {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}
197201 </MenuItem>
198202 </MenuPopup>
......@@ -250,7 +254,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
250254 if (links.length === 0) {
251255 return (
252256 <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
253 <LinkIcon aria-hidden className="size-6 text-muted-foreground/60" />
257 <PullRequestGlyph.link aria-hidden className="size-6 text-muted-foreground/60" />
254258 <p className="text-sm font-medium">No linked pull requests</p>
255259 <p className="max-w-60 text-xs text-muted-foreground">
256260 Pull requests the agent opens from this thread land here. Link one yourself from a URL or
apps/web/src/components/pullRequest/pullRequestIcons.tsx created+54
......@@ -0,0 +1,54 @@
1import {
2 GitMergeIcon,
3 GitPullRequestArrowIcon,
4 GitPullRequestClosedIcon,
5 GitPullRequestDraftIcon,
6 LayersIcon,
7 Link2Icon,
8 Unlink2Icon,
9 TriangleAlertIcon,
10} from "lucide-react";
11import type { PullRequestState } from "@t3tools/contracts";
12
13export const PullRequestGlyph = {
14 pullRequest: GitPullRequestArrowIcon,
15 reopen: GitPullRequestArrowIcon,
16 draft: GitPullRequestDraftIcon,
17 closed: GitPullRequestClosedIcon,
18 merged: GitMergeIcon,
19 conflicting: TriangleAlertIcon,
20 stack: LayersIcon,
21 link: Link2Icon,
22 unlink: Unlink2Icon,
23} as const;
24
25export type PullRequestGlyphIcon = (typeof PullRequestGlyph)[keyof typeof PullRequestGlyph];
26
27export interface PullRequestStatePresentation {
28 readonly label: string;
29 readonly toneClassName: string;
30 readonly Icon: PullRequestGlyphIcon;
31}
32
33export const PULL_REQUEST_STATE_PRESENTATION = {
34 open: {
35 label: "Open",
36 toneClassName: "text-emerald-600 dark:text-emerald-300/90",
37 Icon: PullRequestGlyph.pullRequest,
38 },
39 draft: {
40 label: "Draft",
41 toneClassName: "text-zinc-500 dark:text-zinc-400/80",
42 Icon: PullRequestGlyph.draft,
43 },
44 closed: {
45 label: "Closed",
46 toneClassName: "text-red-600 dark:text-red-300/90",
47 Icon: PullRequestGlyph.closed,
48 },
49 merged: {
50 label: "Merged",
51 toneClassName: "text-violet-600 dark:text-violet-300/90",
52 Icon: PullRequestGlyph.merged,
53 },
54} as const satisfies Record<PullRequestState | "draft", PullRequestStatePresentation>;
apps/web/src/components/pullRequest/pullRequestPresentation.test.ts created+139
......@@ -0,0 +1,139 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolvePullRequestConflict, resolvePullRequestState } from "./pullRequestPresentation";
4import { PullRequestGlyph } from "./pullRequestIcons";
5
6describe("resolvePullRequestState", () => {
7 it.each([
8 [
9 "open",
10 { state: "open", isDraft: false },
11 PullRequestGlyph.pullRequest,
12 "Open",
13 "text-emerald-600 dark:text-emerald-300/90",
14 ],
15 [
16 "draft",
17 { state: "open", isDraft: true },
18 PullRequestGlyph.draft,
19 "Draft",
20 "text-zinc-500 dark:text-zinc-400/80",
21 ],
22 [
23 "closed",
24 { state: "closed", isDraft: false },
25 PullRequestGlyph.closed,
26 "Closed",
27 "text-red-600 dark:text-red-300/90",
28 ],
29 [
30 "merged",
31 { state: "merged", isDraft: false },
32 PullRequestGlyph.merged,
33 "Merged",
34 "text-violet-600 dark:text-violet-300/90",
35 ],
36 ] as const)(
37 "resolves the %s lifecycle presentation",
38 (_name, input, Icon, label, toneClassName) => {
39 expect(resolvePullRequestState(input)).toEqual({
40 Icon,
41 label,
42 toneClassName,
43 });
44 },
45 );
46
47 it("keeps a merged pull request merged when stale draft metadata is also present", () => {
48 expect(resolvePullRequestState({ state: "merged", isDraft: true })).toMatchObject({
49 Icon: PullRequestGlyph.merged,
50 label: "Merged",
51 });
52 });
53
54 it("keeps a closed pull request closed when stale draft metadata is also present", () => {
55 expect(resolvePullRequestState({ state: "closed", isDraft: true })).toMatchObject({
56 Icon: PullRequestGlyph.closed,
57 label: "Closed",
58 });
59 });
60
61 it("keeps lifecycle and conflict presentation independent for an open conflicting pull request", () => {
62 const input = {
63 state: "open" as const,
64 isDraft: false,
65 mergeability: "conflicting" as const,
66 baseBranch: "main",
67 };
68
69 expect(resolvePullRequestState(input)).toMatchObject({
70 Icon: PullRequestGlyph.pullRequest,
71 label: "Open",
72 });
73 expect(resolvePullRequestConflict(input)).toMatchObject({
74 Icon: PullRequestGlyph.conflicting,
75 label: "Conflicts with main",
76 toneClassName: "text-destructive",
77 });
78 });
79});
80
81describe("resolvePullRequestConflict", () => {
82 it.each([
83 ["closed", { state: "closed", isDraft: false }],
84 ["merged", { state: "merged", isDraft: false }],
85 ["closed draft", { state: "closed", isDraft: true }],
86 ["merged draft", { state: "merged", isDraft: true }],
87 ["open draft", { state: "open", isDraft: true }],
88 ] as const)("does not report a conflict for %s", (_name, input) => {
89 expect(
90 resolvePullRequestConflict({
91 ...input,
92 mergeability: "conflicting",
93 baseBranch: "main",
94 }),
95 ).toBeNull();
96 });
97
98 it.each([
99 ["omitted", undefined],
100 ["unknown", "unknown"],
101 ["mergeable", "mergeable"],
102 ] as const)("does not report an open conflict when mergeability is %s", (_name, mergeability) => {
103 const input = {
104 state: "open" as const,
105 isDraft: false,
106 ...(mergeability === undefined ? {} : { mergeability }),
107 };
108 expect(resolvePullRequestConflict(input)).toBeNull();
109 });
110
111 it("reports a known conflict with its base branch", () => {
112 expect(
113 resolvePullRequestConflict({
114 state: "open",
115 isDraft: false,
116 mergeability: "conflicting",
117 baseBranch: "main",
118 }),
119 ).toEqual({
120 Icon: PullRequestGlyph.conflicting,
121 label: "Conflicts with main",
122 toneClassName: "text-destructive",
123 });
124 });
125
126 it("reports a known conflict without inventing a base branch", () => {
127 expect(
128 resolvePullRequestConflict({
129 state: "open",
130 isDraft: false,
131 mergeability: "conflicting",
132 }),
133 ).toEqual({
134 Icon: PullRequestGlyph.conflicting,
135 label: "Has conflicts",
136 toneClassName: "text-destructive",
137 });
138 });
139});
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+60-54
......@@ -12,11 +12,6 @@ import {
1212 CircleDashedIcon,
1313 CircleDotIcon,
1414 CircleXIcon,
15 GitMergeIcon,
16 GitPullRequestClosedIcon,
17 GitPullRequestDraftIcon,
18 GitPullRequestIcon,
19 TriangleAlertIcon,
2015 UserCheckIcon,
2116} from "lucide-react";
2217import { Children, isValidElement, type ReactNode } from "react";
......@@ -26,12 +21,12 @@ import { cn } from "~/lib/utils";
2621import { Badge } from "../ui/badge";
2722import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2823import type { PullRequestReviewOutcome } from "./pullRequestDetail.logic";
29
30interface StatePresentation {
31 readonly label: string;
32 readonly toneClassName: string;
33 readonly Icon: typeof GitPullRequestIcon;
34}
24import {
25 PULL_REQUEST_STATE_PRESENTATION,
26 PullRequestGlyph,
27 type PullRequestStatePresentation,
28 type PullRequestGlyphIcon,
29} from "./pullRequestIcons";
3530
3631export function PullRequestApprovalGlyph() {
3732 return (
......@@ -49,57 +44,69 @@ export function PullRequestApprovalGlyph() {
4944}
5045
5146/**
52 * How a pull request's state reads on this page. Open, closed, merged, and draft use the same
53 * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two
54 * different things in two places.
47 * How a pull request's state reads anywhere it appears: the thread badge, the right-panel tab,
48 * the list, and the detail header all resolve through here so one pull request cannot look like
49 * two different things in two places.
5550 *
56 * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface
57 * once it is real work.
51 * Closed and merged take precedence over a stale draft flag.
5852 */
5953export function resolvePullRequestState(input: {
6054 readonly state: PullRequestState;
6155 readonly isDraft: boolean;
56}): PullRequestStatePresentation {
57 const key = input.state === "open" && input.isDraft ? "draft" : input.state;
58 return PULL_REQUEST_STATE_PRESENTATION[key];
59}
60
61export interface PullRequestConflictPresentation {
62 readonly label: string;
63 readonly toneClassName: string;
64 readonly Icon: PullRequestGlyphIcon;
65}
66
67export function resolvePullRequestConflict(input: {
68 readonly state: PullRequestState;
69 readonly isDraft: boolean;
6270 readonly mergeability?: PullRequestMergeability;
6371 readonly baseBranch?: string;
64}): StatePresentation {
65 if (input.state === "merged") {
66 return {
67 label: "Merged",
68 toneClassName: "text-violet-600 dark:text-violet-300/90",
69 Icon: GitMergeIcon,
70 };
71 }
72 if (input.state === "closed") {
73 return {
74 label: "Closed",
75 toneClassName: "text-red-600 dark:text-red-300/90",
76 Icon: GitPullRequestClosedIcon,
77 };
78 }
79 if (input.isDraft) {
80 return {
81 label: "Draft",
82 toneClassName: "text-zinc-500 dark:text-zinc-400/80",
83 Icon: GitPullRequestDraftIcon,
84 };
85 }
86 if (input.mergeability === "conflicting") {
87 return {
88 // "Has conflicts" leaves out the one thing a reader wants when the warning triangle catches
89 // their eye, so name the branch it collides with wherever the caller knows it.
90 label: input.baseBranch ? `Conflicts with ${input.baseBranch}` : "Has conflicts",
91 toneClassName: "text-destructive",
92 Icon: TriangleAlertIcon,
93 };
72}): PullRequestConflictPresentation | null {
73 if (input.state !== "open" || input.isDraft || input.mergeability !== "conflicting") {
74 return null;
9475 }
9576 return {
96 label: "Open",
97 toneClassName: "text-emerald-600 dark:text-emerald-300/90",
98 Icon: GitPullRequestIcon,
77 label: input.baseBranch ? `Conflicts with ${input.baseBranch}` : "Has conflicts",
78 toneClassName: "text-destructive",
79 Icon: PullRequestGlyph.conflicting,
9980 };
10081}
10182
10283export function PullRequestStateGlyph({
84 state,
85 isDraft,
86 className,
87}: {
88 state: PullRequestState;
89 isDraft: boolean;
90 className?: string;
91}) {
92 const presentation = resolvePullRequestState({ state, isDraft });
93 return (
94 <Tooltip>
95 {/* The list row is itself a button, so the trigger stays a span: an interactive one would
96 nest a control inside that button and steal the row's click target. */}
97 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
98 <presentation.Icon
99 role="img"
100 aria-label={presentation.label}
101 className={cn("size-4 shrink-0", presentation.toneClassName, className)}
102 />
103 </TooltipTrigger>
104 <TooltipPopup>{presentation.label}</TooltipPopup>
105 </Tooltip>
106 );
107}
108
109export function PullRequestConflictGlyph({
103110 state,
104111 isDraft,
105112 mergeability,
......@@ -112,16 +119,15 @@ export function PullRequestStateGlyph({
112119 baseBranch?: string;
113120 className?: string;
114121}) {
115 const presentation = resolvePullRequestState({
122 const presentation = resolvePullRequestConflict({
116123 state,
117124 isDraft,
118 ...(mergeability ? { mergeability } : {}),
119 ...(baseBranch ? { baseBranch } : {}),
125 ...(mergeability === undefined ? {} : { mergeability }),
126 ...(baseBranch === undefined ? {} : { baseBranch }),
120127 });
128 if (presentation === null) return null;
121129 return (
122130 <Tooltip>
123 {/* The list row is itself a button, so the trigger stays a span: an interactive one would
124 nest a control inside that button and steal the row's click target. */}
125131 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
126132 <presentation.Icon
127133 role="img"
apps/web/src/components/settings/SourceControlSettings.tsx+3-2
......@@ -1,5 +1,5 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react";
2import { ChevronDownIcon } from "lucide-react";
33import * as Duration from "effect/Duration";
44import * as Option from "effect/Option";
55import { useEffect, useState, type ReactNode } from "react";
......@@ -67,6 +67,7 @@ import {
6767 useSettingsSearchTargetId,
6868} from "./settingsLayout";
6969import { searchableSetting } from "./settingsSearch";
70import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
7071
7172const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
7273 versionControlSystems: [],
......@@ -476,7 +477,7 @@ function EmptySourceControlDiscovery({
476477 <SettingsSection id={searchableSetting("source-control").id} title="Server environment">
477478 <Empty className="min-h-88">
478479 <EmptyMedia variant="icon">
479 <GitPullRequestIcon />
480 <PullRequestGlyph.pullRequest />
480481 </EmptyMedia>
481482 <EmptyHeader>
482483 <EmptyTitle>
apps/web/src/components/sidebar/SidebarChrome.tsx+3-7
......@@ -1,9 +1,4 @@
1import {
2 ArrowLeftIcon,
3 ChartNoAxesColumnIcon,
4 GitPullRequestIcon,
5 SettingsIcon,
6} from "lucide-react";
1import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react";
72import type { ReactNode } from "react";
83import { memo, useCallback } from "react";
94import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
......@@ -33,6 +28,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3328import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
3429import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
3530import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
31import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
3632
3733export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3834 isElectron,
......@@ -205,7 +201,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
205201 />
206202 {pullRequestsSupported ? (
207203 <SidebarUtilityItem
208 icon={<GitPullRequestIcon />}
204 icon={<PullRequestGlyph.pullRequest />}
209205 label="Pull Requests"
210206 onClick={handlePullRequestsClick}
211207 />
apps/web/src/routes/_chat.pull-requests.tsx+4-6
......@@ -21,9 +21,6 @@ import {
2121 ChevronDownIcon,
2222 ClockIcon,
2323 EyeIcon,
24 GitMergeIcon,
25 GitPullRequestClosedIcon,
26 GitPullRequestIcon,
2724 LayersIcon,
2825 ListChecksIcon,
2926 PenLineIcon,
......@@ -150,6 +147,7 @@ import { useAtomCommand } from "../state/use-atom-command";
150147import { cn } from "~/lib/utils";
151148import { primaryServerKeybindingsAtom } from "~/state/server";
152149import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
150import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
153151
154152function getShortcutContext() {
155153 return {
......@@ -196,9 +194,9 @@ const INVOLVEMENT_TABS = [
196194
197195const STATE_TABS = [
198196 { value: "all", label: "All", Icon: LayersIcon },
199 { value: "open", label: "Open", Icon: GitPullRequestIcon },
200 { value: "closed", label: "Closed", Icon: GitPullRequestClosedIcon },
201 { value: "merged", label: "Merged", Icon: GitMergeIcon },
197 { value: "open", label: "Open", Icon: PullRequestGlyph.pullRequest },
198 { value: "closed", label: "Closed", Icon: PullRequestGlyph.closed },
199 { value: "merged", label: "Merged", Icon: PullRequestGlyph.merged },
202200] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
203201
204202const SORT_OPTIONS = [
apps/web/src/sourceControlPresentation.ts+3-3
......@@ -1,4 +1,3 @@
1import { GitPullRequestIcon } from "lucide-react";
21import type { ElementType } from "react";
32import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
43export {
......@@ -16,10 +15,11 @@ import {
1615import {
1716 AzureDevOpsIcon,
1817 BitbucketIcon,
18 ForgejoIcon,
1919 GitHubIcon,
2020 GitLabIcon,
21 ForgejoIcon,
2221} from "./components/Icons";
22import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
2323
2424export interface SourceControlPresentation {
2525 readonly providerName: string;
......@@ -66,7 +66,7 @@ export function getSourceControlPresentation(
6666 return {
6767 providerName: provider?.name || presentation.providerName,
6868 terminology: getChangeRequestTerminology(provider),
69 Icon: GitPullRequestIcon,
69 Icon: PullRequestGlyph.pullRequest,
7070 };
7171 }
7272}
vite.config.ts+44-15
......@@ -2,6 +2,43 @@ import "vite-plus/test/config";
22import { defineConfig } from "vite-plus";
33import * as NodeURL from "node:url";
44
5/** Import restrictions every file keeps, including the one module exempt from the glyph rule. */
6const RESTRICTED_IMPORT_PATHS = [
7 {
8 name: "@t3tools/client-runtime",
9 message:
10 "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.",
11 },
12 {
13 name: "@pierre/diffs/react",
14 importNames: ["CodeView"],
15 message: "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.",
16 },
17];
18
19/** Lucide's pull-request glyphs, which only `pullRequestIcons.tsx` may name. */
20const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = {
21 name: "lucide-react",
22 importNames: [
23 "GitMerge",
24 "GitMergeIcon",
25 "GitPullRequest",
26 "GitPullRequestIcon",
27 "GitPullRequestArrow",
28 "GitPullRequestArrowIcon",
29 "GitPullRequestClosed",
30 "GitPullRequestClosedIcon",
31 "GitPullRequestDraft",
32 "GitPullRequestDraftIcon",
33 "GitPullRequestCreate",
34 "GitPullRequestCreateIcon",
35 "GitPullRequestCreateArrow",
36 "GitPullRequestCreateArrowIcon",
37 ],
38 message:
39 "Pick a glyph by meaning from PullRequestGlyph in apps/web/src/components/pullRequest/pullRequestIcons.tsx so every surface draws the same pull request the same way.",
40};
41
542export default defineConfig({
643 resolve: {
744 alias: {
......@@ -103,21 +140,7 @@ export default defineConfig({
103140 "typescript/unbound-method": "off",
104141 "eslint/no-restricted-imports": [
105142 "error",
106 {
107 paths: [
108 {
109 name: "@t3tools/client-runtime",
110 message:
111 "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.",
112 },
113 {
114 name: "@pierre/diffs/react",
115 importNames: ["CodeView"],
116 message:
117 "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.",
118 },
119 ],
120 },
143 { paths: [...RESTRICTED_IMPORT_PATHS, RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS] },
121144 ],
122145 "t3code/no-global-process-runtime": "error",
123146 "t3code/no-inline-schema-compile": "warn",
......@@ -131,6 +154,12 @@ export default defineConfig({
131154 files: ["packages/shared/src/hostProcess.ts"],
132155 rules: { "t3code/no-global-process-runtime": "off" },
133156 },
157 {
158 // The one module allowed to name lucide's pull-request glyphs; everything else picks
159 // from its vocabulary. The other import restrictions still apply here.
160 files: ["apps/web/src/components/pullRequest/pullRequestIcons.tsx"],
161 rules: { "eslint/no-restricted-imports": ["error", { paths: RESTRICTED_IMPORT_PATHS }] },
162 },
134163 {
135164 files: ["apps/mobile/src/**"],
136165 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },