authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 00:16:57-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 00:16:57-04:00
logd35c71d1b975660ec6a71bbceb6aa7dfd0e8c3d5
treed798297d076ca5a498e073d8934bd85fae92f734
parentb17cc3d1bf0f4a5deee6dd6a470e36970b864a9b
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

7 files changed, 585 insertions(+), 154 deletions(-)

apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx+4-3
......@@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode {
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", () => {
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", () => {
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 {
1818 ListFilterIcon,
1919 LoaderIcon,
2020 SearchIcon,
21 TagIcon,
22 UserRoundIcon,
2123} from "lucide-react";
22import type { ElementType } from "react";
24import { type ElementType, useState } from "react";
2325
24import { cn } from "~/lib/utils";
2526import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
2627import { ProjectFavicon } from "../ProjectFavicon";
2728import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
......@@ -29,27 +30,56 @@ import { Button } from "../ui/button";
2930
3031import {
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";
3945import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46import {
47 pullRequestLabelColor,
48 type PullRequestAuthorFacet,
49 type PullRequestLabelFacet,
50} from "./pullRequestList.logic";
51import { PullRequestActorAvatar } from "./pullRequestPresentation";
4052
4153export 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
66export 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
5383export interface PullRequestExpectedHost {
5484 readonly host: string;
5585 readonly kind: SourceControlProviderKind;
......@@ -98,10 +128,8 @@ export function PullRequestSearchInput({
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 */
106134const 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>({
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>({
188217 );
189218}
190219
220function 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
254function 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
331function 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
191397export function PullRequestFiltersMenu({
398 onOpenChange,
192399 state,
193400 stateOptions,
194401 onState,
......@@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({
197404 onInvolvement,
198405 filters,
199406 onFilters,
407 authorOptions = [],
408 labelOptions = [],
200409 host,
201410 hostOptions,
202411 onHost,
......@@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({
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({
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({
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({
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({
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";
77
88import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
99import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
10import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
10import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic";
1111import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
1212import {
1313 PullRequestActorLabel,
......@@ -16,6 +16,23 @@ import {
1616 PullRequestStateGlyph,
1717} from "./pullRequestPresentation";
1818
19function 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
1936function PullRequestRowImpl({
2037 entry,
2138 selected,
......@@ -118,6 +135,7 @@ function PullRequestRowImpl({
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";
5757import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
5858import { PullRequestReactionBar } from "./PullRequestReactions";
5959import { PullRequestConversationGhost } from "./PullRequestGhosts";
60import { pullRequestLabelColor } from "./pullRequestList.logic";
6061import { 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 {
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. */
68function 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. */
7469function CommentAuthor({ actor }: { actor: PullRequestActor | null }) {
7570 const login = actor?.login ?? "ghost";
......@@ -619,7 +614,7 @@ export function PullRequestSummaryTab({
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 {
88 resolvePullRequestAuthorFilter,
99} from "@t3tools/contracts";
1010import 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
4042 readonly entries: ReadonlyArray<Entry>;
4143}
4244
45export interface PullRequestAuthorFacet {
46 readonly actor: PullRequestActor;
47 readonly count: number;
48 readonly mergedCount: number;
49}
50
51export 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 {
6577 return trimmed.length > 0 ? trimmed : null;
6678}
6779
80export 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
85export 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(
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. */
442const diffStatKey = (row: {
510export const pullRequestDiffStatKey = (row: {
443511 readonly environmentId: string;
444512 readonly projectId: string;
445513 readonly number: number;
......@@ -792,6 +860,6 @@ export function withDiffStat<
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 {
1313} from "@t3tools/contracts";
1414import { createFileRoute, useNavigate } from "@tanstack/react-router";
1515import {
16 ArrowDownUpIcon,
17 CalendarArrowDownIcon,
18 CalendarArrowUpIcon,
1619 ChevronDownIcon,
20 ClockIcon,
1721 EyeIcon,
1822 MonitorIcon,
1923 ServerIcon,
......@@ -23,6 +27,8 @@ import {
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
3137import {
3238 filterPullRequestsByInvolvement,
3339 findScopedProject,
40 collectPullRequestListFacets,
3441 groupPullRequestsByInvolvement,
3542 matchesPullRequestFilters,
3643 matchesPullRequestQuery,
......@@ -38,6 +45,7 @@ import {
3845 narrowPullRequestsToFilters,
3946 mergePullRequestDiffStats,
4047 partitionPullRequestsWithPriority,
48 pullRequestDiffStatKey,
4149 pullRequestEntryKey,
4250 pullRequestEntryViewer,
4351 rankPullRequestMatches,
......@@ -58,6 +66,7 @@ import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequ
5866import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
5967import {
6068 PullRequestFiltersMenu,
69 PullRequestFilterOptionIcon,
6170 PullRequestSearchInput,
6271 pullRequestHostLabel,
6372 pullRequestProjectKey,
......@@ -83,6 +92,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../
8392import { SidebarInset } from "../components/ui/sidebar";
8493import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
8594import { useLiveRefresh } from "../hooks/useLiveRefresh";
95import { toSortableTimestamp } from "../lib/threadSort";
8696import {
8797 selectActiveRightPanelSurface,
8898 selectSelectedRightPanelSurface,
......@@ -136,8 +146,13 @@ export interface PullRequestsSearch {
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
154type 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.
142157const INVOLVEMENT_TABS = [
143158 { value: "all", label: "All", Icon: LayersIcon },
......@@ -152,6 +167,14 @@ const STATE_TABS = [
152167 { value: "merged", label: "Merged", Icon: GitMergeIcon },
153168] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
154169
170const 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. */
156179const 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 = {};
181204const EMPTY_PREVIEW_DESKTOP_STATE = {};
182205const EMPTY_TERMINAL_LABELS = new Map<string, string>();
183206const EMPTY_PENDING_SURFACES = new Set<string>();
207const MAX_SEARCH_LABEL_CANDIDATES = 100;
208
209function 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
185228export const Route = createFileRoute("/_chat/pull-requests")({
186229 validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({
......@@ -188,6 +231,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({
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")({
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
223273function 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() {
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() {
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() {
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() {
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() {
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() {
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() {
720792 }
721793 refreshList();
722794 baselineQuery.refresh();
795 facetQuery.refresh();
723796 authoredQuery.refresh();
724797 reviewingQuery.refresh();
725798 statsQuery.refresh();
......@@ -982,6 +1055,17 @@ function PullRequestsRouteView() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
16251762/** A compact stand-in for one pill group when the header is narrow. */
16261763function 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>({
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>({
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({
17691926 onState,
17701927 onHost,
17711928 searchInput,
1929 sortMenu,
17721930 filtersMenu,
17731931 rightPanelControl,
17741932 titlebarControls,
......@@ -1786,6 +1944,7 @@ function PullRequestsColumn({
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({
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
......@@ -41,6 +41,9 @@ T3 Code works with the platforms your team already uses:
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