History

feat(web): add pull request list filters (#8809)

maria committed 2 months ago

Commit d35c71d1 parent b17cc3d7 files · +585 −154Browse files

Unified · Split

7 files
  1. apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx +4 −3
  2. apps/web/src/components/pullRequest/PullRequestListFilters.tsx +309 −123
  3. apps/web/src/components/pullRequest/PullRequestRow.tsx +19 −1
  4. apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +2 −7
  5. apps/web/src/components/pullRequest/pullRequestList.logic.ts +71 −3
  6. apps/web/src/routes/_chat.pull-requests.tsx +177 −17
  7. docs/user/source-control.md +3 −0
apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx+4−3
@@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode { · 33 unmodified lines
3434 if (!isValidElement(child)) continue;
3535 const props = child.props as { readonly children?: ReactNode; readonly label?: string };
3636 if (props.label === label && typeof child.type === "function") {
37− return (child.type as (properties: unknown) => ReactNode)(child.props);
37+ const rendered = (child.type as (properties: unknown) => ReactNode)(child.props);
38+ return findLabeledGroup(rendered, label) ?? rendered;
3839 }
3940 const nested = findLabeledGroup(props.children, label);
4041 if (nested !== undefined) return nested;
@@ -126,7 +127,7 @@ describe("pull request filters menu", () => { · 85 unmodified lines
126127 projectEnvironmentId: environmentId,
127128 onProject,
128129 });
129− const radioGroup = findValueChange(view);
130+ const radioGroup = findValueChange(findLabeledGroup(view, "Project"));
130131 expect(radioGroup).toBeDefined();
131132
132133 radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId }));
@@ -156,7 +157,7 @@ describe("pull request filters menu", () => { · 23 unmodified lines
156157 ],
157158 onProject,
158159 });
159− const radioGroup = findValueChange(view);
160+ const radioGroup = findValueChange(findLabeledGroup(view, "Project"));
160161 expect(radioGroup).toBeDefined();
161162
162163 radioGroup?.props.onValueChange(
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+309−123
@@ -18,10 +18,11 @@ import { · 17 unmodified lines
1818 ListFilterIcon,
1919 LoaderIcon,
2020 SearchIcon,
21+ TagIcon,
22+ UserRoundIcon,
2123 } from "lucide-react";
22−import type { ElementType } from "react";
24+import { type ElementType, useState } from "react";
2325
24−import { cn } from "~/lib/utils";
2526 import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
2627 import { ProjectFavicon } from "../ProjectFavicon";
2728 import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
@@ -29,27 +30,56 @@ import { Button } from "../ui/button"; · 1 unmodified line
2930
3031 import {
3132 Menu,
33+ MenuCheckboxItem,
3234 MenuGroupLabel,
35+ MenuItem,
3336 MenuPopup,
3437 MenuRadioGroup,
3538 MenuRadioItem,
3639 MenuSeparator,
40+ MenuSub,
41+ MenuSubPopup,
42+ MenuSubTrigger,
3743 MenuTrigger,
3844 } from "../ui/menu";
3945 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46+import {
47+ pullRequestLabelColor,
48+ type PullRequestAuthorFacet,
49+ type PullRequestLabelFacet,
50+} from "./pullRequestList.logic";
51+import { PullRequestActorAvatar } from "./pullRequestPresentation";
4052
4153 export interface PullRequestFilterOption<Value extends string> {
4254 readonly value: Value;
4355 readonly label: string;
44− /**
45− * Carries the option's own tone, so an icon reads the same here as it does on a row. Left
46− * uncoloured, which lets the item's selected state stay the thing the eye follows.
47− */
56+ /** Uses the option's native icon tone. */
4857 readonly Icon: ElementType<{ className?: string }>;
58+ readonly favicon?: {
59+ readonly environmentId: EnvironmentId;
60+ readonly cwd: string;
61+ };
4962 /** Why it cannot be chosen, carried onto the item as its title. */
5063 readonly unavailable?: string | undefined;
5164 }
5265
66+export function PullRequestFilterOptionIcon<Value extends string>({
67+ option,
68+}: {
69+ option: PullRequestFilterOption<Value>;
70+}) {
71+ return option.favicon ? (
72+ <ProjectFavicon
73+ environmentId={option.favicon.environmentId}
74+ cwd={option.favicon.cwd}
75+ fallbackIcon={FolderGit2Icon}
76+ className="size-3.5"
77+ />
78+ ) : (
79+ <option.Icon aria-hidden className="size-3.5" />
80+ );
81+}
82+
5383 export interface PullRequestExpectedHost {
5484 readonly host: string;
5585 readonly kind: SourceControlProviderKind;
@@ -98,10 +128,8 @@ export function PullRequestSearchInput({ · 42 unmodified lines
98128 }
99129
100130 /**
101− * Every list filter lives behind the one filter icon so the control row stays two controls
102− * wide: the search and this. The trigger carries a dot whenever any filter is off its
103− * default, so a narrowed list is never a mystery. Same menu chrome as the detail panel's
104− * actions, which also owns its own spacing.
131+ * List narrowings live behind one filter control, separate from sorting. The trigger carries a
132+ * count whenever any filter is off its default, so a narrowed list is never a mystery.
105133 */
106134 const ALL_PROJECTS_VALUE = "all";
107135 /** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */
@@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup<Value extends string>({ · 61 unmodified lines
169197 disabled={option.unavailable !== undefined}
170198 >
171199 <span className="flex min-w-0 items-center gap-2">
172− <option.Icon aria-hidden className="size-3.5" />
173− {option.label}
200+ <PullRequestFilterOptionIcon option={option} />
201+ <span className="min-w-0 flex-1 truncate">{option.label}</span>
202+ {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null}
174203 </span>
175204 </MenuRadioItem>
176205 );
@@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup<Value extends string>({ · 11 unmodified lines
188217 );
189218 }
190219
220+function PullRequestFilterRadioSubmenu<Value extends string>({
221+ label,
222+ value,
223+ options,
224+ onChange,
225+}: {
226+ label: string;
227+ value: Value;
228+ options: ReadonlyArray<PullRequestFilterOption<Value>>;
229+ onChange: (value: Value) => void;
230+}) {
231+ const current = options.find((option) => option.value === value) ?? options[0];
232+ if (!current) return null;
233+ return (
234+ <MenuSub>
235+ <MenuSubTrigger>
236+ <PullRequestFilterOptionIcon option={current} />
237+ <span className="flex-1">{label}</span>
238+ <span className="min-w-0 max-w-32 truncate text-xs text-muted-foreground">
239+ {current.label}
240+ </span>
241+ </MenuSubTrigger>
242+ <MenuSubPopup className="min-w-56">
243+ <PullRequestFilterRadioGroup
244+ label={label}
245+ value={value}
246+ options={options}
247+ onChange={onChange}
248+ />
249+ </MenuSubPopup>
250+ </MenuSub>
251+ );
252+}
253+
254+function PullRequestAuthorFilter({
255+ value,
256+ options,
257+ onChange,
258+}: {
259+ value: string | undefined;
260+ options: ReadonlyArray<PullRequestAuthorFacet>;
261+ onChange: (author: string | undefined) => void;
262+}) {
263+ const [query, setQuery] = useState("");
264+ const needle = query.trim().toLowerCase();
265+ const login = value?.toLowerCase() ?? "";
266+ const selected = options.find((option) => option.actor.login.toLowerCase() === login);
267+ const visible = [
268+ ...(selected ? [selected] : []),
269+ ...options.filter(
270+ (option) =>
271+ option !== selected &&
272+ (needle.length === 0 ||
273+ option.actor.login.toLowerCase().includes(needle) ||
274+ option.actor.name?.toLowerCase().includes(needle)),
275+ ),
276+ ].slice(0, 10);
277+ const select = (next: string) => next.toLowerCase() !== login && onChange(next || undefined);
278+ return (
279+ <MenuSub>
280+ <MenuSubTrigger>
281+ <UserRoundIcon aria-hidden className="size-3.5" />
282+ <span className="flex-1">Author</span>
283+ <span className="min-w-0 max-w-32 truncate text-xs text-muted-foreground">
284+ {value ?? "Anyone"}
285+ </span>
286+ </MenuSubTrigger>
287+ <MenuSubPopup className="w-80">
288+ <div className="p-1 pb-2">
289+ <InputGroup>
290+ <InputGroupAddon>
291+ <SearchIcon aria-hidden />
292+ </InputGroupAddon>
293+ <InputGroupInput
294+ autoFocus
295+ size="compact"
296+ value={query}
297+ onChange={(event) => setQuery(event.currentTarget.value)}
298+ onKeyDown={(event) => {
299+ if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation();
300+ }}
301+ placeholder="Search authors"
302+ aria-label="Search authors"
303+ />
304+ </InputGroup>
305+ </div>
306+ <MenuRadioGroup value={selected?.actor.login ?? value ?? ""} onValueChange={select}>
307+ <MenuRadioItem value="">
308+ <span className="flex min-w-0 items-center gap-2">
309+ <LayersIcon aria-hidden className="size-3.5" />
310+ Anyone
311+ </span>
312+ </MenuRadioItem>
313+ {visible.map((option) => (
314+ <MenuRadioItem key={option.actor.login.toLowerCase()} value={option.actor.login}>
315+ <span className="flex min-w-0 items-center gap-2">
316+ <PullRequestActorAvatar actor={option.actor} />
317+ <span className="min-w-0 flex-1 truncate">{option.actor.login}</span>
318+ <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
319+ {option.mergedCount} merges loaded
320+ </span>
321+ </span>
322+ </MenuRadioItem>
323+ ))}
324+ {visible.length === 0 ? <MenuItem disabled>No authors found</MenuItem> : null}
325+ </MenuRadioGroup>
326+ </MenuSubPopup>
327+ </MenuSub>
328+ );
329+}
330+
331+function PullRequestLabelFilter({
332+ value,
333+ options,
334+ onChange,
335+}: {
336+ value: ReadonlyArray<string>;
337+ options: ReadonlyArray<PullRequestLabelFacet>;
338+ onChange: (labels: ReadonlyArray<string>) => void;
339+}) {
340+ const selected = new Set(value.map((name) => name.toLowerCase()));
341+ const visible = [
342+ ...value
343+ .filter((name) => !options.some((option) => option.name.toLowerCase() === name.toLowerCase()))
344+ .map((name) => ({ name, color: null, count: 0 })),
345+ ...options,
346+ ];
347+ return (
348+ <MenuSub>
349+ <MenuSubTrigger>
350+ <TagIcon aria-hidden className="size-3.5" />
351+ <span className="flex-1">Labels</span>
352+ <span className="text-xs text-muted-foreground">
353+ {value.length === 0 ? "Any" : `${value.length} selected`}
354+ </span>
355+ </MenuSubTrigger>
356+ <MenuSubPopup className="w-72">
357+ {visible.length === 0 ? (
358+ <MenuItem disabled>No labels in this view</MenuItem>
359+ ) : (
360+ visible.map((option) => {
361+ const key = option.name.toLowerCase();
362+ const checked = selected.has(key);
363+ const dot = pullRequestLabelColor(option.color);
364+ return (
365+ <MenuCheckboxItem
366+ key={key}
367+ className="grid-cols-[1rem_minmax(0,1fr)]"
368+ checked={checked}
369+ onCheckedChange={(next) =>
370+ onChange(
371+ next
372+ ? [...value, option.name]
373+ : value.filter((name) => name.toLowerCase() !== option.name.toLowerCase()),
374+ )
375+ }
376+ >
377+ <span className="flex min-w-0 items-center gap-2">
378+ <span
379+ aria-hidden
380+ className="size-2.5 shrink-0 rounded-full bg-muted-foreground"
381+ {...(dot ? { style: { backgroundColor: dot } } : {})}
382+ />
383+ <span className="min-w-0 flex-1 truncate">{option.name}</span>
384+ <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
385+ {option.count}
386+ </span>
387+ </span>
388+ </MenuCheckboxItem>
389+ );
390+ })
391+ )}
392+ </MenuSubPopup>
393+ </MenuSub>
394+ );
395+}
396+
191397 export function PullRequestFiltersMenu({
398+ onOpenChange,
192399 state,
193400 stateOptions,
194401 onState,
@@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({ · 2 unmodified lines
197404 onInvolvement,
198405 filters,
199406 onFilters,
407+ authorOptions = [],
408+ labelOptions = [],
200409 host,
201410 hostOptions,
202411 onHost,
@@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({ · 6 unmodified lines
209418 unavailable,
210419 onProject,
211420 }: {
421+ onOpenChange?: (open: boolean) => void;
212422 state: PullRequestListState;
213423 stateOptions: ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
214424 onState: (state: PullRequestListState) => void;
@@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({ · 3 unmodified lines
218428 /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */
219429 filters: PullRequestListFilters;
220430 onFilters: (filters: PullRequestListFilters) => void;
431+ authorOptions?: ReadonlyArray<PullRequestAuthorFacet>;
432+ labelOptions?: ReadonlyArray<PullRequestLabelFacet>;
221433 host: string | undefined;
222434 /**
223435 * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real
@@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({ · 30 unmodified lines
254466 /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */
255467 onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void;
256468 }) {
257− const filtered =
258− state !== "open" ||
259− involvement !== "all" ||
260− host !== undefined ||
261− server !== undefined ||
262− projectId !== undefined ||
263− Object.keys(filters).length > 0;
264− /**
265− * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in
266− * it as an explicit `undefined`, which the listing input does not accept.
267− */
268− const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters =>
269− Object.fromEntries(
270− Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter(
271− ([, held]) => held !== undefined,
272− ),
273− ) as PullRequestListFilters;
469+ const selectedLabels = (filters.labels ?? []).flatMap((group) => group);
470+ const filterCount = [
471+ state !== "open",
472+ involvement !== "all",
473+ host,
474+ server,
475+ projectId,
476+ filters.draft,
477+ filters.review,
478+ filters.checks,
479+ filters.author,
480+ ...selectedLabels,
481+ ].filter(Boolean).length;
482+ const updateFilters = (next: Partial<PullRequestListFilters>) =>
483+ onFilters(
484+ Object.fromEntries(
485+ Object.entries({ ...filters, ...next }).filter(([, value]) => value !== undefined),
486+ ) as PullRequestListFilters,
487+ );
488+ const updateFilter = (key: keyof PullRequestListFilters, value: string) =>
489+ updateFilters({
490+ [key]: value === UNFILTERED_VALUE ? undefined : value,
491+ } as Partial<PullRequestListFilters>);
492+ const projectValue =
493+ projectId === undefined || projectEnvironmentId === undefined
494+ ? ALL_PROJECTS_VALUE
495+ : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId });
496+ const projectOptions: ReadonlyArray<PullRequestFilterOption<string>> = [
497+ { value: ALL_PROJECTS_VALUE, label: "All projects", Icon: LayersIcon },
498+ ...projects
499+ .toSorted(
500+ (left, right) =>
501+ Number(unavailable.has(pullRequestProjectKey(left))) -
502+ Number(unavailable.has(pullRequestProjectKey(right))),
503+ )
504+ .map((project) => ({
505+ value: pullRequestProjectKey(project),
506+ label: project.title,
507+ Icon: FolderGit2Icon,
508+ favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot },
509+ ...(unavailable.has(pullRequestProjectKey(project))
510+ ? { unavailable: unavailable.get(pullRequestProjectKey(project)) }
511+ : {}),
512+ })),
513+ ];
274514 return (
275− <Menu>
515+ <Menu onOpenChange={onOpenChange}>
276516 <MenuTrigger
277517 render={
278518 <Button
279− className={cn("relative", filtered && "[--control-icon-color:currentColor]")}
280− size="icon"
519+ className={filterCount > 0 ? "[--control-icon-color:currentColor]" : undefined}
281520 variant="outline"
282− aria-label="Filter pull requests"
283521 />
284522 }
285523 >
286524 <ListFilterIcon className="size-4" />
287− {filtered ? (
288− <span
289− aria-hidden
290− className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"
291− />
525+ <span>Filters</span>
526+ {filterCount > 0 ? (
527+ <span className="rounded-full bg-primary/10 px-1.5 text-xs text-primary tabular-nums">
528+ {filterCount}
529+ </span>
292530 ) : null}
293531 </MenuTrigger>
294− <MenuPopup align="end" side="bottom" className="min-w-56">
295− <PullRequestFilterRadioGroup
532+ <MenuPopup align="end" side="bottom" className="w-56">
533+ <PullRequestFilterRadioSubmenu
296534 label="State"
297535 value={state}
298536 options={stateOptions}
299537 onChange={onState}
300538 />
301− <MenuSeparator />
302− <PullRequestFilterRadioGroup
539+ <PullRequestFilterRadioSubmenu
303540 label="Involvement"
304541 value={involvement}
305542 options={involvementOptions}
306543 onChange={onInvolvement}
307544 />
308545 <MenuSeparator />
309− <PullRequestFilterRadioGroup
546+ <PullRequestAuthorFilter
547+ value={filters.author}
548+ options={authorOptions}
549+ onChange={(author) => updateFilters({ author })}
550+ />
551+ <PullRequestLabelFilter
552+ value={selectedLabels}
553+ options={labelOptions}
554+ onChange={(labels) =>
555+ updateFilters({
556+ labels: labels.length === 0 ? undefined : labels.slice(0, 10).map((label) => [label]),
557+ })
558+ }
559+ />
560+ <PullRequestFilterRadioSubmenu
310561 label="Draft"
311562 value={filters.draft ?? UNFILTERED_VALUE}
312563 options={DRAFT_OPTIONS}
313− onChange={(next) => onFilters(withFilter("draft", next))}
564+ onChange={(draft) => updateFilter("draft", draft)}
314565 />
315− <MenuSeparator />
316− <PullRequestFilterRadioGroup
566+ <PullRequestFilterRadioSubmenu
317567 label="Review"
318568 value={filters.review ?? UNFILTERED_VALUE}
319569 options={REVIEW_OPTIONS}
320− onChange={(next) => onFilters(withFilter("review", next))}
570+ onChange={(review) => updateFilter("review", review)}
321571 />
322− <MenuSeparator />
323− <PullRequestFilterRadioGroup
572+ <PullRequestFilterRadioSubmenu
324573 label="Checks"
325574 value={filters.checks ?? UNFILTERED_VALUE}
326575 options={CHECKS_OPTIONS}
327− onChange={(next) => onFilters(withFilter("checks", next))}
576+ onChange={(checks) => updateFilter("checks", checks)}
328577 />
329578 {hostOptions.length > 2 ? (
330579 <>
331580 <MenuSeparator />
332− <PullRequestFilterRadioGroup
581+ <PullRequestFilterRadioSubmenu
333582 label="Host"
334583 value={host ?? ALL_HOSTS_VALUE}
335584 options={hostOptions}
@@ -340,7 +589,7 @@ export function PullRequestFiltersMenu({ · 4 unmodified lines
340589 {serverOptions.length > 2 ? (
341590 <>
342591 <MenuSeparator />
343− <PullRequestFilterRadioGroup
592+ <PullRequestFilterRadioSubmenu
344593 label="Server"
345594 value={server ?? ALL_SERVERS_VALUE}
346595 options={serverOptions}
@@ -351,79 +600,16 @@ export function PullRequestFiltersMenu({ · 4 unmodified lines
351600 </>
352601 ) : null}
353602 <MenuSeparator />
354− <MenuRadioGroup
355− value={
356− projectId === undefined || projectEnvironmentId === undefined
357− ? ALL_PROJECTS_VALUE
358− : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId })
359− }
360− onValueChange={(next) => {
361− if (next === ALL_PROJECTS_VALUE) {
362− if (projectId !== undefined) onProject(undefined, undefined);
363− return;
364− }
365− // The value carries both halves, since the id alone cannot tell two servers' rows
366− // apart once they share one.
603+ <PullRequestFilterRadioSubmenu
604+ label="Project"
605+ value={projectValue}
606+ options={projectOptions}
607+ onChange={(next) => {
367608 const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next);
368− if (
369− project !== undefined &&
370− (project.id !== projectId || project.environmentId !== projectEnvironmentId)
371− ) {
372− onProject(project.id, project.environmentId);
373− }
609+ if (project) onProject(project.id, project.environmentId);
610+ else if (projectId !== undefined) onProject(undefined, undefined);
374611 }}
375− >
376− <MenuGroupLabel>Project</MenuGroupLabel>
377− <MenuRadioItem value={ALL_PROJECTS_VALUE}>
378− <span className="flex min-w-0 items-center gap-2">
379− <LayersIcon aria-hidden className="size-3.5" />
380− All projects
381− </span>
382− </MenuRadioItem>
383− {/* The ones that can be chosen first: a list that opens with three disabled rows reads
384− as a broken menu rather than as a workspace with three unreadable repositories. */}
385− {projects
386− .toSorted(
387− (left, right) =>
388− Number(unavailable.has(pullRequestProjectKey(left))) -
389− Number(unavailable.has(pullRequestProjectKey(right))),
390− )
391− .map((project) => {
392− const reason = unavailable.get(pullRequestProjectKey(project));
393− const item = (
394− <MenuRadioItem
395− key={pullRequestProjectKey(project)}
396− value={pullRequestProjectKey(project)}
397− className={reason !== undefined ? "data-disabled:pointer-events-auto" : undefined}
398− disabled={reason !== undefined}
399− >
400− <span className="flex min-w-0 flex-1 items-center gap-2">
401− <ProjectFavicon
402− environmentId={project.environmentId}
403− cwd={project.workspaceRoot}
404− fallbackIcon={FolderGit2Icon}
405− className="size-3.5 shrink-0"
406− />
407− <span className="min-w-0 flex-1 truncate">{project.title}</span>
408− {reason === undefined ? null : (
409− <span className="shrink-0 rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-px text-[10px] font-medium text-amber-600 dark:text-amber-400/90">
410− Unavailable
411− </span>
412− )}
413− </span>
414− </MenuRadioItem>
415− );
416− if (reason === undefined) return item;
417− return (
418− <Tooltip key={pullRequestProjectKey(project)}>
419− <TooltipTrigger render={item} />
420− <TooltipPopup side="top" className="max-w-80">
421− {reason}
422− </TooltipPopup>
423− </Tooltip>
424− );
425− })}
426− </MenuRadioGroup>
612+ />
427613 </MenuPopup>
428614 </Menu>
429615 );
apps/web/src/components/pullRequest/PullRequestRow.tsx+19−1
@@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; · 6 unmodified lines
77
88 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
99 import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
10−import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
10+import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic";
1111 import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
1212 import {
1313 PullRequestActorLabel,
@@ -16,6 +16,23 @@ import { · 2 unmodified lines
1616 PullRequestStateGlyph,
1717 } from "./pullRequestPresentation";
1818
19+function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) {
20+ const label = labels[0];
21+ if (!label) return null;
22+ const dot = pullRequestLabelColor(label.color);
23+ return (
24+ <span className="inline-flex max-w-40 min-w-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 py-0 pl-1 pr-1.5 text-[10px] leading-3.5 text-muted-foreground">
25+ <span
26+ aria-hidden
27+ className="size-2 shrink-0 rounded-full bg-muted-foreground"
28+ {...(dot ? { style: { backgroundColor: dot } } : {})}
29+ />
30+ <span className="truncate">{label.name}</span>
31+ {labels.length > 1 ? <span className="shrink-0">+{labels.length - 1}</span> : null}
32+ </span>
33+ );
34+}
35+
1936 function PullRequestRowImpl({
2037 entry,
2138 selected,
@@ -118,6 +135,7 @@ function PullRequestRowImpl({ · 96 unmodified lines
118135 className="min-w-4 max-w-40"
119136 labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate"
120137 />
138+ {entry.labels.length > 0 ? <PullRequestRowLabels labels={entry.labels} /> : null}
121139 {/* Only a verdict somebody has actually given: "review required" is the absence of
122140 one, and saying so on every unreviewed row would say nothing. */}
123141 {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+2−7
@@ -57,6 +57,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; · 56 unmodified lines
5757 import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
5858 import { PullRequestReactionBar } from "./PullRequestReactions";
5959 import { PullRequestConversationGhost } from "./PullRequestGhosts";
60+import { pullRequestLabelColor } from "./pullRequestList.logic";
6061 import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic";
6162
6263 /** One reviewer, however a host happens to have cased their login this time. */
@@ -64,12 +65,6 @@ function reviewerKey(login: string): string { · 1 unmodified line
6465 return login.toLowerCase();
6566 }
6667
67−/** A host colour only when it is one, so a malformed value falls back to the neutral dot. */
68−function labelDotColor(color: string | null): string | null {
69− const hex = color?.trim().replace(/^#/, "") ?? "";
70− return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
71−}
72−
7368 /** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */
7469 function CommentAuthor({ actor }: { actor: PullRequestActor | null }) {
7570 const login = actor?.login ?? "ghost";
@@ -619,7 +614,7 @@ export function PullRequestSummaryTab({ · 543 unmodified lines
619614 <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels">
620615 <span className="flex min-w-0 flex-wrap items-center gap-1">
621616 {detail.labels.map((label) => {
622− const dot = labelDotColor(label.color);
617+ const dot = pullRequestLabelColor(label.color);
623618 return (
624619 <span
625620 key={label.name}
apps/web/src/components/pullRequest/pullRequestList.logic.ts+71−3
@@ -8,8 +8,10 @@ import { · 7 unmodified lines
88 resolvePullRequestAuthorFilter,
99 } from "@t3tools/contracts";
1010 import type {
11+ PullRequestActor,
1112 PullRequestDiffStat,
1213 PullRequestInvolvement,
14+ PullRequestLabel,
1315 PullRequestListCursors,
1416 PullRequestListFilters,
1517 PullRequestListState,
@@ -40,6 +42,16 @@ export interface PullRequestGroup<Entry extends PullRequestListEntry = PullReque · 24 unmodified lines
4042 readonly entries: ReadonlyArray<Entry>;
4143 }
4244
45+export interface PullRequestAuthorFacet {
46+ readonly actor: PullRequestActor;
47+ readonly count: number;
48+ readonly mergedCount: number;
49+}
50+
51+export interface PullRequestLabelFacet extends PullRequestLabel {
52+ readonly count: number;
53+}
54+
4355 /**
4456 * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than
4557 * one environment: two machines can both reach github.com signed in as different people, and a
@@ -65,6 +77,59 @@ function normalize(value: string | null | undefined): string | null { · 19 unmodified lines
6577 return trimmed.length > 0 ? trimmed : null;
6678 }
6779
80+export function pullRequestLabelColor(color: string | null): string | null {
81+ const hex = color?.trim().replace(/^#/, "") ?? "";
82+ return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
83+}
84+
85+export function collectPullRequestListFacets(
86+ entries: ReadonlyArray<PullRequestListEntry>,
87+ state: PullRequestListState,
88+) {
89+ const authors = new Map<string, PullRequestAuthorFacet>();
90+ const labels = new Map<string, PullRequestLabelFacet>();
91+ const uniqueEntries = new Map(entries.map((entry) => [pullRequestEntryKey(entry), entry]));
92+ for (const entry of uniqueEntries.values()) {
93+ const inState = state === "all" || entry.state === state;
94+ if (entry.author !== null) {
95+ const key = normalize(entry.author.login);
96+ if (key !== null) {
97+ const held = authors.get(key);
98+ authors.set(key, {
99+ actor: held?.actor ?? entry.author,
100+ count: (held?.count ?? 0) + Number(inState),
101+ mergedCount: (held?.mergedCount ?? 0) + Number(entry.state === "merged"),
102+ });
103+ }
104+ }
105+ if (!inState) continue;
106+ for (const label of entry.labels) {
107+ const key = normalize(label.name);
108+ if (key === null) continue;
109+ const held = labels.get(key);
110+ labels.set(key, {
111+ ...label,
112+ name: held?.name ?? label.name,
113+ color: held?.color ?? label.color,
114+ count: (held?.count ?? 0) + 1,
115+ });
116+ }
117+ }
118+ return {
119+ authors: [...authors.values()]
120+ .filter((author) => author.count > 0)
121+ .toSorted(
122+ (left, right) =>
123+ right.mergedCount - left.mergedCount ||
124+ right.count - left.count ||
125+ left.actor.login.localeCompare(right.actor.login),
126+ ),
127+ labels: [...labels.values()].toSorted(
128+ (left, right) => right.count - left.count || left.name.localeCompare(right.name),
129+ ),
130+ };
131+}
132+
68133 /**
69134 * The signed-in login for the host a row came from, or null where none was given. Shared by
70135 * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed.
@@ -433,13 +498,16 @@ export function mergePullRequestDiffStats( · 362 unmodified lines
433498 if (stats.length === 0) return previous;
434499 const next = new Map(previous);
435500 for (const stat of stats) {
436− next.set(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions });
501+ next.set(pullRequestDiffStatKey(stat), {
502+ additions: stat.additions,
503+ deletions: stat.deletions,
504+ });
437505 }
438506 return next;
439507 }
440508
441509 /** A project id only names a project within its own environment, so the key carries both. */
442−const diffStatKey = (row: {
510+export const pullRequestDiffStatKey = (row: {
443511 readonly environmentId: string;
444512 readonly projectId: string;
445513 readonly number: number;
@@ -792,6 +860,6 @@ export function withDiffStat< · 346 unmodified lines
792860 statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>,
793861 ): Entry {
794862 if (entry.additions !== 0 || entry.deletions !== 0) return entry;
795− const stat = statsByRow.get(diffStatKey(entry));
863+ const stat = statsByRow.get(pullRequestDiffStatKey(entry));
796864 return stat === undefined ? entry : { ...entry, ...stat };
797865 }
apps/web/src/routes/_chat.pull-requests.tsx+177−17
@@ -13,7 +13,11 @@ import type { · 12 unmodified lines
1313 } from "@t3tools/contracts";
1414 import { createFileRoute, useNavigate } from "@tanstack/react-router";
1515 import {
16+ ArrowDownUpIcon,
17+ CalendarArrowDownIcon,
18+ CalendarArrowUpIcon,
1619 ChevronDownIcon,
20+ ClockIcon,
1721 EyeIcon,
1822 MonitorIcon,
1923 ServerIcon,
@@ -23,6 +27,8 @@ import { · 3 unmodified lines
2327 LayersIcon,
2428 PenLineIcon,
2529 LoaderIcon,
30+ Maximize2Icon,
31+ Minimize2Icon,
2632 RefreshCwIcon,
2733 SearchIcon,
2834 } from "lucide-react";
@@ -31,6 +37,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro · 2 unmodified lines
3137 import {
3238 filterPullRequestsByInvolvement,
3339 findScopedProject,
40+ collectPullRequestListFacets,
3441 groupPullRequestsByInvolvement,
3542 matchesPullRequestFilters,
3643 matchesPullRequestQuery,
@@ -38,6 +45,7 @@ import { · 1 unmodified line
3845 narrowPullRequestsToFilters,
3946 mergePullRequestDiffStats,
4047 partitionPullRequestsWithPriority,
48+ pullRequestDiffStatKey,
4149 pullRequestEntryKey,
4250 pullRequestEntryViewer,
4351 rankPullRequestMatches,
@@ -58,6 +66,7 @@ import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequ · 14 unmodified lines
5866 import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
5967 import {
6068 PullRequestFiltersMenu,
69+ PullRequestFilterOptionIcon,
6170 PullRequestSearchInput,
6271 pullRequestHostLabel,
6372 pullRequestProjectKey,
@@ -83,6 +92,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ · 19 unmodified lines
8392 import { SidebarInset } from "../components/ui/sidebar";
8493 import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
8594 import { useLiveRefresh } from "../hooks/useLiveRefresh";
95+import { toSortableTimestamp } from "../lib/threadSort";
8696 import {
8797 selectActiveRightPanelSurface,
8898 selectSelectedRightPanelSurface,
@@ -136,8 +146,13 @@ export interface PullRequestsSearch { · 47 unmodified lines
136146 readonly draft?: "only" | "hide";
137147 readonly review?: NonNullable<PullRequestListFilters["review"]>;
138148 readonly checks?: NonNullable<PullRequestListFilters["checks"]>;
149+ readonly author?: string;
150+ readonly labels?: ReadonlyArray<string>;
151+ readonly sort?: PullRequestListSort;
139152 }
140153
154+type PullRequestListSort = "updated" | "newest" | "oldest" | "largest" | "smallest";
155+
141156 // The state filters wear the same glyphs the rows do, so the two read as one vocabulary.
142157 const INVOLVEMENT_TABS = [
143158 { value: "all", label: "All", Icon: LayersIcon },
@@ -152,6 +167,14 @@ const STATE_TABS = [ · 8 unmodified lines
152167 { value: "merged", label: "Merged", Icon: GitMergeIcon },
153168 ] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
154169
170+const SORT_OPTIONS = [
171+ { value: "updated", label: "Recently updated", Icon: ClockIcon },
172+ { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon },
173+ { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon },
174+ { value: "largest", label: "Largest shown", Icon: Maximize2Icon },
175+ { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon },
176+] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListSort>>;
177+
155178 /** Long enough that a keystroke does not become a request, short enough to feel answered. */
156179 const SEARCH_DEBOUNCE_MS = 250;
157180 /** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */
@@ -181,6 +204,26 @@ const EMPTY_PREVIEW_SESSIONS = {}; · 23 unmodified lines
181204 const EMPTY_PREVIEW_DESKTOP_STATE = {};
182205 const EMPTY_TERMINAL_LABELS = new Map<string, string>();
183206 const EMPTY_PENDING_SURFACES = new Set<string>();
207+const MAX_SEARCH_LABEL_CANDIDATES = 100;
208+
209+function pullRequestSearchLabels(raw: unknown): Partial<Pick<PullRequestsSearch, "labels">> {
210+ const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice(
211+ 0,
212+ MAX_SEARCH_LABEL_CANDIDATES,
213+ );
214+ const labels: Array<string> = [];
215+ const seen = new Set<string>();
216+ for (const rawValue of values) {
217+ if (typeof rawValue !== "string") continue;
218+ const value = rawValue.trim().slice(0, 200);
219+ const key = value.toLowerCase();
220+ if (value.length === 0 || seen.has(key)) continue;
221+ labels.push(value);
222+ seen.add(key);
223+ if (labels.length === 10) break;
224+ }
225+ return labels.length === 0 ? {} : { labels };
226+}
184227
185228 export const Route = createFileRoute("/_chat/pull-requests")({
186229 validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({
@@ -188,6 +231,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ · 1 unmodified line
188231 raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all",
189232 state:
190233 raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open",
234+ ...(SORT_OPTIONS.some((option) => option.value === raw.sort)
235+ ? { sort: raw.sort as PullRequestListSort }
236+ : {}),
191237 ...(typeof raw.repository === "string" && raw.repository
192238 ? { repository: raw.repository.slice(0, 200) }
193239 : {}),
@@ -216,12 +262,17 @@ export const Route = createFileRoute("/_chat/pull-requests")({ · 22 unmodified lines
216262 ? { review: raw.review }
217263 : {}),
218264 ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}),
265+ ...(typeof raw.author === "string" && raw.author.trim()
266+ ? { author: raw.author.trim().slice(0, 200) }
267+ : {}),
268+ ...pullRequestSearchLabels(raw.labels),
219269 }),
220270 component: PullRequestsRouteView,
221271 });
222272
223273 function PullRequestsRouteView() {
224274 const search = Route.useSearch();
275+ const sort = search.sort ?? "updated";
225276 const navigate = useNavigate({ from: Route.fullPath });
226277 const { environments } = useEnvironments();
227278 // Every connected environment that has said it can list pull requests. Sorted, so the query
@@ -416,6 +467,7 @@ function PullRequestsRouteView() { · 188 unmodified lines
416467 return {
417468 involvement: next.involvement ?? previous.involvement,
418469 state: next.state ?? previous.state,
470+ ...(next.sort && next.sort !== "updated" ? { sort: next.sort } : {}),
419471 ...(next.repository ? { repository: next.repository } : {}),
420472 ...(next.number ? { number: next.number } : {}),
421473 ...(next.projectId ? { projectId: next.projectId } : {}),
@@ -429,6 +481,8 @@ function PullRequestsRouteView() { · 7 unmodified lines
429481 ...(next.draft ? { draft: next.draft } : {}),
430482 ...(next.review ? { review: next.review } : {}),
431483 ...(next.checks ? { checks: next.checks } : {}),
484+ ...(next.author ? { author: next.author } : {}),
485+ ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}),
432486 };
433487 },
434488 replace: true,
@@ -458,6 +512,7 @@ function PullRequestsRouteView() { · 23 unmodified lines
458512 // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is
459513 // late but the page is not.
460514 const typedQuery = (search.q ?? "").trim();
515+ const [filtersOpen, setFiltersOpen] = useState(false);
461516 const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS);
462517 const querySettled = typedQuery === sentQuery;
463518 // What was typed, split into the qualifiers the hosts can act on and the words that are left.
@@ -472,12 +527,14 @@ function PullRequestsRouteView() { · 8 unmodified lines
472527 ...(search.draft ? { draft: search.draft } : {}),
473528 ...(search.review ? { review: search.review } : {}),
474529 ...(search.checks ? { checks: search.checks } : {}),
530+ ...(search.author ? { author: search.author } : {}),
531+ ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}),
475532 }),
476− [search.checks, search.draft, search.review],
533+ [search.author, search.checks, search.draft, search.labels, search.review],
477534 );
478535 const menuFiltered = Object.keys(menuFilters).length > 0;
479536 // A typed qualifier wins over the menu's own answer for the same thing, since it is the more
480− // recent word on it; labels only ever come from the query, so there is nothing to overrule.
537+ // recent word on it, including a typed author or label over its menu counterpart.
481538 const filters = useMemo(
482539 (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }),
483540 [menuFilters, sentParsed.filters],
@@ -548,7 +605,7 @@ function PullRequestsRouteView() { · 64 unmodified lines
548605 [environmentQueries],
549606 );
550607 // Page size is view state, not a URL concern: a shared link should open the first page.
551− const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}`;
608+ const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`;
552609 const filterKey = `${scopeKey}:${sentQuery}`;
553610 // Where the next slice carries on from, per repository within each environment, as that
554611 // environment handed it back. Sending it is what makes a second page cost a second page rather
@@ -660,6 +717,21 @@ function PullRequestsRouteView() { · 105 unmodified lines
660717 ],
661718 );
662719 const baselineQuery = usePullRequestList(baselineTargets);
720+ const facetTargets = useMemo(() => {
721+ if (!filtersOpen) return NO_LIST_TARGETS;
722+ return environmentQueries.map(({ environmentId, projectIds }) => ({
723+ environmentId,
724+ input: {
725+ state: "all",
726+ involvement: search.involvement,
727+ limit: PAGE_SIZE,
728+ ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
729+ ...(projectIds ? { projectIds } : {}),
730+ ...(search.host ? { host: search.host } : {}),
731+ } satisfies PullRequestListInput,
732+ }));
733+ }, [environmentQueries, filtersOpen, scopedProjectId, search.host, search.involvement]);
734+ const facetQuery = usePullRequestList(facetTargets);
663735 // The priority groups' own reads. The feed below is paginated by recency, so an older authored
664736 // or review-requested row can be missing from its first page; partitioned from these
665737 // server-filtered reads instead, the priority view is complete up front and a continuation can
@@ -720,6 +792,7 @@ function PullRequestsRouteView() { · 54 unmodified lines
720792 }
721793 refreshList();
722794 baselineQuery.refresh();
795+ facetQuery.refresh();
723796 authoredQuery.refresh();
724797 reviewingQuery.refresh();
725798 statsQuery.refresh();
@@ -982,6 +1055,17 @@ function PullRequestsRouteView() { · 256 unmodified lines
9821055
9831056 const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS;
9841057 const listErrors = baselineQuery.data?.errors ?? listData?.errors ?? [];
1058+ const facets = useMemo(
1059+ () =>
1060+ collectPullRequestListFacets(
1061+ [
1062+ ...(facetQuery.data?.entries ?? []),
1063+ ...(baselineQuery.data?.entries ?? listData?.entries ?? []),
1064+ ],
1065+ search.state,
1066+ ),
1067+ [baselineQuery.data?.entries, facetQuery.data?.entries, listData?.entries, search.state],
1068+ );
9851069
9861070 /** The hosts that narrowed the listing themselves, so their answer is not narrowed again. */
9871071 const searchingHosts = useMemo(
@@ -1170,6 +1254,40 @@ function PullRequestsRouteView() { · 182 unmodified lines
11701254 if (stats === null) return;
11711255 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));
11721256 }, [statsQuery.stats]);
1257+ const displayGroups = useMemo(() => {
1258+ const enriched = groups.map((group) => ({
1259+ ...group,
1260+ entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)),
1261+ }));
1262+ if (sort === "updated") return enriched;
1263+ const entries = enriched.flatMap((group) => group.entries);
1264+ const hasSize = (entry: (typeof entries)[number]) =>
1265+ entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry));
1266+ const timestamp = (entry: (typeof entries)[number]) =>
1267+ toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0;
1268+ return [
1269+ {
1270+ key: "others" as const,
1271+ label: "",
1272+ entries: entries.toSorted((left, right) => {
1273+ if (sort === "newest" || sort === "oldest") {
1274+ const leftCreated = toSortableTimestamp(left.createdAt);
1275+ const rightCreated = toSortableTimestamp(right.createdAt);
1276+ const measured = Number(rightCreated !== null) - Number(leftCreated !== null);
1277+ const dated = (leftCreated ?? 0) - (rightCreated ?? 0);
1278+ return (
1279+ measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left)
1280+ );
1281+ }
1282+ const measured = Number(hasSize(right)) - Number(hasSize(left));
1283+ const sized = left.additions + left.deletions - (right.additions + right.deletions);
1284+ return (
1285+ measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left)
1286+ );
1287+ }),
1288+ },
1289+ ];
1290+ }, [groups, sort, statsByRow]);
11731291
11741292 const linkedSelection = useMemo(
11751293 () =>
@@ -1347,6 +1465,7 @@ function PullRequestsRouteView() { · 171 unmodified lines
13471465 onRefresh={() => void refreshFromHost()}
13481466 query={typedQuery}
13491467 filtered={
1468+ menuFiltered ||
13501469 search.state !== "open" ||
13511470 search.involvement !== "all" ||
13521471 scopedProjectId !== undefined ||
@@ -1360,7 +1479,7 @@ function PullRequestsRouteView() { · 7 unmodified lines
13601479 />
13611480 ) : (
13621481 <div className="space-y-3">
1363− {groups.map((group) => (
1482+ {displayGroups.map((group) => (
13641483 <div key={group.key} className="space-y-0.5">
13651484 {group.label ? (
13661485 <h2 className="px-3 pb-0.5 text-xs font-medium text-muted-foreground/70">
@@ -1370,10 +1489,7 @@ function PullRequestsRouteView() { · 3 unmodified lines
13701489 {group.entries.map((entry) => (
13711490 <PullRequestRow
13721491 key={pullRequestEntryKey(entry)}
1373− // A row whose host reported its line counts keeps them; one whose host left
1374− // them for later takes whatever has arrived since, and draws without them
1375− // until it does.
1376− entry={withDiffStat(entry, statsByRow)}
1492+ entry={entry}
13771493 showProjectTitle
13781494 showProvider={showProvider}
13791495 {...(capableEnvironments.length > 1 &&
@@ -1449,8 +1565,20 @@ function PullRequestsRouteView() { · 69 unmodified lines
14491565 Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon,
14501566 })),
14511567 ];
1568+ const sortMenu = (
1569+ <CompactFilterMenu
1570+ label="Sort pull requests"
1571+ triggerIcon={<ArrowDownUpIcon aria-hidden className="size-4" />}
1572+ triggerLabel="Sort"
1573+ outlined
1574+ value={sort}
1575+ options={SORT_OPTIONS}
1576+ onChange={(next) => updateSearch({ sort: next })}
1577+ />
1578+ );
14521579 const filtersMenu = (
14531580 <PullRequestFiltersMenu
1581+ onOpenChange={setFiltersOpen}
14541582 state={search.state}
14551583 stateOptions={STATE_TABS}
14561584 onState={(state) => updateListScope({ state })}
@@ -1459,8 +1587,16 @@ function PullRequestsRouteView() { · 2 unmodified lines
14591587 onInvolvement={(involvement) => updateListScope({ involvement })}
14601588 filters={menuFilters}
14611589 onFilters={(next) =>
1462− updateListScope({ draft: next.draft, review: next.review, checks: next.checks })
1590+ updateListScope({
1591+ draft: next.draft,
1592+ review: next.review,
1593+ checks: next.checks,
1594+ author: next.author,
1595+ labels: next.labels?.flatMap((group) => group),
1596+ })
14631597 }
1598+ authorOptions={facets.authors}
1599+ labelOptions={facets.labels}
14641600 host={search.host}
14651601 hostOptions={hostMenuOptions}
14661602 onHost={(host) => updateListScope({ host })}
@@ -1493,6 +1629,7 @@ function PullRequestsRouteView() { · 26 unmodified lines
14931629 onState: (state: PullRequestListState) => updateListScope({ state }),
14941630 onHost: (host: string | undefined) => updateListScope({ host }),
14951631 searchInput,
1632+ sortMenu,
14961633 filtersMenu,
14971634 rightPanelControl:
14981635 // Footprint reserve while the panel is closed: the toggle itself stays
@@ -1625,12 +1762,18 @@ function PullRequestsRouteView() { · 126 unmodified lines
16251762 /** A compact stand-in for one pill group when the header is narrow. */
16261763 function CompactFilterMenu<Value extends string>({
16271764 label,
1765+ triggerIcon,
1766+ triggerLabel,
1767+ outlined = false,
16281768 value,
16291769 options,
16301770 onChange,
16311771 className,
16321772 }: {
16331773 label: string;
1774+ triggerIcon?: ReactNode;
1775+ triggerLabel?: string;
1776+ outlined?: boolean;
16341777 value: Value;
16351778 options: ReadonlyArray<PullRequestFilterOption<Value>>;
16361779 onChange: (value: Value) => void;
@@ -1641,14 +1784,28 @@ function CompactFilterMenu<Value extends string>({ · 4 unmodified lines
16411784 return (
16421785 <Menu>
16431786 <MenuTrigger
1644− aria-label={label}
1645− className={cn(
1646− "inline-flex h-7 min-w-0 items-center gap-1 rounded-md px-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground",
1647− className,
1648− )}
1787+ aria-label={triggerLabel ? `${label}: ${current.label}` : label}
1788+ render={outlined ? <Button variant="outline" /> : undefined}
1789+ className={
1790+ outlined
1791+ ? className
1792+ : cn(
1793+ "inline-flex h-7 min-w-0 items-center gap-1 rounded-md px-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground",
1794+ className,
1795+ )
1796+ }
16491797 >
1650− <span className="truncate">{current.label}</span>
1651− <ChevronDownIcon aria-hidden className="size-3 shrink-0 text-muted-foreground/70" />
1798+ {triggerLabel ? (
1799+ <>
1800+ {triggerIcon}
1801+ <span>{triggerLabel}</span>
1802+ </>
1803+ ) : (
1804+ <>
1805+ <span className="truncate">{current.label}</span>
1806+ <ChevronDownIcon aria-hidden className="size-3 shrink-0 text-muted-foreground/70" />
1807+ </>
1808+ )}
16521809 </MenuTrigger>
16531810 <MenuPopup align="start" side="bottom" className="min-w-40">
16541811 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
@@ -1661,7 +1818,7 @@ function CompactFilterMenu<Value extends string>({ · 6 unmodified lines
16611818 className="data-disabled:pointer-events-auto"
16621819 >
16631820 <span className="flex min-w-0 items-center gap-2">
1664− <option.Icon aria-hidden className="size-3.5" />
1821+ <PullRequestFilterOptionIcon option={option} />
16651822 {option.label}
16661823 </span>
16671824 </MenuRadioItem>
@@ -1769,6 +1926,7 @@ function PullRequestsColumn({ · 101 unmodified lines
17691926 onState,
17701927 onHost,
17711928 searchInput,
1929+ sortMenu,
17721930 filtersMenu,
17731931 rightPanelControl,
17741932 titlebarControls,
@@ -1786,6 +1944,7 @@ function PullRequestsColumn({ · 11 unmodified lines
17861944 onState: (state: PullRequestListState) => void;
17871945 onHost: (host: string | undefined) => void;
17881946 searchInput: ReactNode;
1947+ sortMenu: ReactNode;
17891948 filtersMenu: ReactNode;
17901949 rightPanelControl: ReactNode;
17911950 titlebarControls: ReactNode;
@@ -1934,6 +2093,7 @@ function PullRequestsColumn({ · 142 unmodified lines
19342093 <div className="flex flex-col gap-3">
19352094 <div ref={inFlowSearchRef} className="flex items-center gap-2">
19362095 {searchInput}
2096+ {sortMenu}
19372097 {filtersMenu}
19382098 {!condensed ? (
19392099 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
docs/user/source-control.md+3−0
@@ -41,6 +41,9 @@ T3 Code works with the platforms your team already uses: · 40 unmodified lines
4141
4242 - See if your current branch already has an open PR/MR
4343 - Open several reviews from the **Pull requests** page as tabs in the right panel
44+- Filter the list by author or labels, rank authors by merges in the loaded results, see label and
45+ change-size context on each row, and sort the results currently shown by update time, creation
46+ time, or change size
4447 - While working in a thread, open linked reviews in the same compact right-panel tabs without
4548 leaving the conversation
4649 - Open the review directly in your browser with one click