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 {...@@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode {
34 if (!isValidElement(child)) continue;34 if (!isValidElement(child)) continue;
35 const props = child.props as { readonly children?: ReactNode; readonly label?: string };35 const props = child.props as { readonly children?: ReactNode; readonly label?: string };
36 if (props.label === label && typeof child.type === "function") {36 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;
38 }39 }
39 const nested = findLabeledGroup(props.children, label);40 const nested = findLabeledGroup(props.children, label);
40 if (nested !== undefined) return nested;41 if (nested !== undefined) return nested;
...@@ -126,7 +127,7 @@ describe("pull request filters menu", () => {...@@ -126,7 +127,7 @@ describe("pull request filters menu", () => {
126 projectEnvironmentId: environmentId,127 projectEnvironmentId: environmentId,
127 onProject,128 onProject,
128 });129 });
129 const radioGroup = findValueChange(view);130 const radioGroup = findValueChange(findLabeledGroup(view, "Project"));
130 expect(radioGroup).toBeDefined();131 expect(radioGroup).toBeDefined();
131132
132 radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId }));133 radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId }));
...@@ -156,7 +157,7 @@ describe("pull request filters menu", () => {...@@ -156,7 +157,7 @@ describe("pull request filters menu", () => {
156 ],157 ],
157 onProject,158 onProject,
158 });159 });
159 const radioGroup = findValueChange(view);160 const radioGroup = findValueChange(findLabeledGroup(view, "Project"));
160 expect(radioGroup).toBeDefined();161 expect(radioGroup).toBeDefined();
161162
162 radioGroup?.props.onValueChange(163 radioGroup?.props.onValueChange(
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+309-123
...@@ -18,10 +18,11 @@ import {...@@ -18,10 +18,11 @@ import {
18 ListFilterIcon,18 ListFilterIcon,
19 LoaderIcon,19 LoaderIcon,
20 SearchIcon,20 SearchIcon,
21 TagIcon,
22 UserRoundIcon,
21} from "lucide-react";23} from "lucide-react";
22import type { ElementType } from "react";24import { type ElementType, useState } from "react";
2325
24import { cn } from "~/lib/utils";
25import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";26import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
26import { ProjectFavicon } from "../ProjectFavicon";27import { ProjectFavicon } from "../ProjectFavicon";
27import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";28import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
...@@ -29,27 +30,56 @@ import { Button } from "../ui/button";...@@ -29,27 +30,56 @@ import { Button } from "../ui/button";
2930
30import {31import {
31 Menu,32 Menu,
33 MenuCheckboxItem,
32 MenuGroupLabel,34 MenuGroupLabel,
35 MenuItem,
33 MenuPopup,36 MenuPopup,
34 MenuRadioGroup,37 MenuRadioGroup,
35 MenuRadioItem,38 MenuRadioItem,
36 MenuSeparator,39 MenuSeparator,
40 MenuSub,
41 MenuSubPopup,
42 MenuSubTrigger,
37 MenuTrigger,43 MenuTrigger,
38} from "../ui/menu";44} from "../ui/menu";
39import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";45import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46import {
47 pullRequestLabelColor,
48 type PullRequestAuthorFacet,
49 type PullRequestLabelFacet,
50} from "./pullRequestList.logic";
51import { PullRequestActorAvatar } from "./pullRequestPresentation";
4052
41export interface PullRequestFilterOption<Value extends string> {53export interface PullRequestFilterOption<Value extends string> {
42 readonly value: Value;54 readonly value: Value;
43 readonly label: string;55 readonly label: string;
44 /**56 /** Uses the option's native icon tone. */
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 */
48 readonly Icon: ElementType<{ className?: string }>;57 readonly Icon: ElementType<{ className?: string }>;
58 readonly favicon?: {
59 readonly environmentId: EnvironmentId;
60 readonly cwd: string;
61 };
49 /** Why it cannot be chosen, carried onto the item as its title. */62 /** Why it cannot be chosen, carried onto the item as its title. */
50 readonly unavailable?: string | undefined;63 readonly unavailable?: string | undefined;
51}64}
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
53export interface PullRequestExpectedHost {83export interface PullRequestExpectedHost {
54 readonly host: string;84 readonly host: string;
55 readonly kind: SourceControlProviderKind;85 readonly kind: SourceControlProviderKind;
...@@ -98,10 +128,8 @@ export function PullRequestSearchInput({...@@ -98,10 +128,8 @@ export function PullRequestSearchInput({
98}128}
99129
100/**130/**
101 * Every list filter lives behind the one filter icon so the control row stays two controls131 * List narrowings live behind one filter control, separate from sorting. The trigger carries a
102 * wide: the search and this. The trigger carries a dot whenever any filter is off its132 * count whenever any filter is off its default, so a narrowed list is never a mystery.
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.
105 */133 */
106const ALL_PROJECTS_VALUE = "all";134const ALL_PROJECTS_VALUE = "all";
107/** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */135/** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */
...@@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup<Value extends string>({...@@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup<Value extends string>({
169 disabled={option.unavailable !== undefined}197 disabled={option.unavailable !== undefined}
170 >198 >
171 <span className="flex min-w-0 items-center gap-2">199 <span className="flex min-w-0 items-center gap-2">
172 <option.Icon aria-hidden className="size-3.5" />200 <PullRequestFilterOptionIcon option={option} />
173 {option.label}201 <span className="min-w-0 flex-1 truncate">{option.label}</span>
202 {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null}
174 </span>203 </span>
175 </MenuRadioItem>204 </MenuRadioItem>
176 );205 );
...@@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup<Value extends string>({...@@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup<Value extends string>({
188 );217 );
189}218}
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
191export function PullRequestFiltersMenu({397export function PullRequestFiltersMenu({
398 onOpenChange,
192 state,399 state,
193 stateOptions,400 stateOptions,
194 onState,401 onState,
...@@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({...@@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({
197 onInvolvement,404 onInvolvement,
198 filters,405 filters,
199 onFilters,406 onFilters,
407 authorOptions = [],
408 labelOptions = [],
200 host,409 host,
201 hostOptions,410 hostOptions,
202 onHost,411 onHost,
...@@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({...@@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({
209 unavailable,418 unavailable,
210 onProject,419 onProject,
211}: {420}: {
421 onOpenChange?: (open: boolean) => void;
212 state: PullRequestListState;422 state: PullRequestListState;
213 stateOptions: ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;423 stateOptions: ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
214 onState: (state: PullRequestListState) => void;424 onState: (state: PullRequestListState) => void;
...@@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({...@@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({
218 /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */428 /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */
219 filters: PullRequestListFilters;429 filters: PullRequestListFilters;
220 onFilters: (filters: PullRequestListFilters) => void;430 onFilters: (filters: PullRequestListFilters) => void;
431 authorOptions?: ReadonlyArray<PullRequestAuthorFacet>;
432 labelOptions?: ReadonlyArray<PullRequestLabelFacet>;
221 host: string | undefined;433 host: string | undefined;
222 /**434 /**
223 * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real435 * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real
...@@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({...@@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({
254 /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */466 /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */
255 onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void;467 onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void;
256}) {468}) {
257 const filtered =469 const selectedLabels = (filters.labels ?? []).flatMap((group) => group);
258 state !== "open" ||470 const filterCount = [
259 involvement !== "all" ||471 state !== "open",
260 host !== undefined ||472 involvement !== "all",
261 server !== undefined ||473 host,
262 projectId !== undefined ||474 server,
263 Object.keys(filters).length > 0;475 projectId,
264 /**476 filters.draft,
265 * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in477 filters.review,
266 * it as an explicit `undefined`, which the listing input does not accept.478 filters.checks,
267 */479 filters.author,
268 const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters =>480 ...selectedLabels,
269 Object.fromEntries(481 ].filter(Boolean).length;
270 Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter(482 const updateFilters = (next: Partial<PullRequestListFilters>) =>
271 ([, held]) => held !== undefined,483 onFilters(
272 ),484 Object.fromEntries(
273 ) as PullRequestListFilters;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 ];
274 return (514 return (
275 <Menu>515 <Menu onOpenChange={onOpenChange}>
276 <MenuTrigger516 <MenuTrigger
277 render={517 render={
278 <Button518 <Button
279 className={cn("relative", filtered && "[--control-icon-color:currentColor]")}519 className={filterCount > 0 ? "[--control-icon-color:currentColor]" : undefined}
280 size="icon"
281 variant="outline"520 variant="outline"
282 aria-label="Filter pull requests"
283 />521 />
284 }522 }
285 >523 >
286 <ListFilterIcon className="size-4" />524 <ListFilterIcon className="size-4" />
287 {filtered ? (525 <span>Filters</span>
288 <span526 {filterCount > 0 ? (
289 aria-hidden527 <span className="rounded-full bg-primary/10 px-1.5 text-xs text-primary tabular-nums">
290 className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"528 {filterCount}
291 />529 </span>
292 ) : null}530 ) : null}
293 </MenuTrigger>531 </MenuTrigger>
294 <MenuPopup align="end" side="bottom" className="min-w-56">532 <MenuPopup align="end" side="bottom" className="w-56">
295 <PullRequestFilterRadioGroup533 <PullRequestFilterRadioSubmenu
296 label="State"534 label="State"
297 value={state}535 value={state}
298 options={stateOptions}536 options={stateOptions}
299 onChange={onState}537 onChange={onState}
300 />538 />
301 <MenuSeparator />539 <PullRequestFilterRadioSubmenu
302 <PullRequestFilterRadioGroup
303 label="Involvement"540 label="Involvement"
304 value={involvement}541 value={involvement}
305 options={involvementOptions}542 options={involvementOptions}
306 onChange={onInvolvement}543 onChange={onInvolvement}
307 />544 />
308 <MenuSeparator />545 <MenuSeparator />
309 <PullRequestFilterRadioGroup546 <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
310 label="Draft"561 label="Draft"
311 value={filters.draft ?? UNFILTERED_VALUE}562 value={filters.draft ?? UNFILTERED_VALUE}
312 options={DRAFT_OPTIONS}563 options={DRAFT_OPTIONS}
313 onChange={(next) => onFilters(withFilter("draft", next))}564 onChange={(draft) => updateFilter("draft", draft)}
314 />565 />
315 <MenuSeparator />566 <PullRequestFilterRadioSubmenu
316 <PullRequestFilterRadioGroup
317 label="Review"567 label="Review"
318 value={filters.review ?? UNFILTERED_VALUE}568 value={filters.review ?? UNFILTERED_VALUE}
319 options={REVIEW_OPTIONS}569 options={REVIEW_OPTIONS}
320 onChange={(next) => onFilters(withFilter("review", next))}570 onChange={(review) => updateFilter("review", review)}
321 />571 />
322 <MenuSeparator />572 <PullRequestFilterRadioSubmenu
323 <PullRequestFilterRadioGroup
324 label="Checks"573 label="Checks"
325 value={filters.checks ?? UNFILTERED_VALUE}574 value={filters.checks ?? UNFILTERED_VALUE}
326 options={CHECKS_OPTIONS}575 options={CHECKS_OPTIONS}
327 onChange={(next) => onFilters(withFilter("checks", next))}576 onChange={(checks) => updateFilter("checks", checks)}
328 />577 />
329 {hostOptions.length > 2 ? (578 {hostOptions.length > 2 ? (
330 <>579 <>
331 <MenuSeparator />580 <MenuSeparator />
332 <PullRequestFilterRadioGroup581 <PullRequestFilterRadioSubmenu
333 label="Host"582 label="Host"
334 value={host ?? ALL_HOSTS_VALUE}583 value={host ?? ALL_HOSTS_VALUE}
335 options={hostOptions}584 options={hostOptions}
...@@ -340,7 +589,7 @@ export function PullRequestFiltersMenu({...@@ -340,7 +589,7 @@ export function PullRequestFiltersMenu({
340 {serverOptions.length > 2 ? (589 {serverOptions.length > 2 ? (
341 <>590 <>
342 <MenuSeparator />591 <MenuSeparator />
343 <PullRequestFilterRadioGroup592 <PullRequestFilterRadioSubmenu
344 label="Server"593 label="Server"
345 value={server ?? ALL_SERVERS_VALUE}594 value={server ?? ALL_SERVERS_VALUE}
346 options={serverOptions}595 options={serverOptions}
...@@ -351,79 +600,16 @@ export function PullRequestFiltersMenu({...@@ -351,79 +600,16 @@ export function PullRequestFiltersMenu({
351 </>600 </>
352 ) : null}601 ) : null}
353 <MenuSeparator />602 <MenuSeparator />
354 <MenuRadioGroup603 <PullRequestFilterRadioSubmenu
355 value={604 label="Project"
356 projectId === undefined || projectEnvironmentId === undefined605 value={projectValue}
357 ? ALL_PROJECTS_VALUE606 options={projectOptions}
358 : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId })607 onChange={(next) => {
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.
367 const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next);608 const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next);
368 if (609 if (project) onProject(project.id, project.environmentId);
369 project !== undefined &&610 else if (projectId !== undefined) onProject(undefined, undefined);
370 (project.id !== projectId || project.environmentId !== projectEnvironmentId)
371 ) {
372 onProject(project.id, project.environmentId);
373 }
374 }}611 }}
375 >612 />
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>
427 </MenuPopup>613 </MenuPopup>
428 </Menu>614 </Menu>
429 );615 );
apps/web/src/components/pullRequest/PullRequestRow.tsx+19-1
...@@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";...@@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";
77
8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
9import { PullRequestChecksPopover } from "./PullRequestChecksPopover";9import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
10import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";10import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic";
11import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";11import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
12import {12import {
13 PullRequestActorLabel,13 PullRequestActorLabel,
...@@ -16,6 +16,23 @@ import {...@@ -16,6 +16,23 @@ import {
16 PullRequestStateGlyph,16 PullRequestStateGlyph,
17} from "./pullRequestPresentation";17} 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
19function PullRequestRowImpl({36function PullRequestRowImpl({
20 entry,37 entry,
21 selected,38 selected,
...@@ -118,6 +135,7 @@ function PullRequestRowImpl({...@@ -118,6 +135,7 @@ function PullRequestRowImpl({
118 className="min-w-4 max-w-40"135 className="min-w-4 max-w-40"
119 labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate"136 labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate"
120 />137 />
138 {entry.labels.length > 0 ? <PullRequestRowLabels labels={entry.labels} /> : null}
121 {/* Only a verdict somebody has actually given: "review required" is the absence of139 {/* Only a verdict somebody has actually given: "review required" is the absence of
122 one, and saying so on every unreviewed row would say nothing. */}140 one, and saying so on every unreviewed row would say nothing. */}
123 {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? (141 {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+2-7
...@@ -57,6 +57,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown";...@@ -57,6 +57,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown";
57import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";57import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
58import { PullRequestReactionBar } from "./PullRequestReactions";58import { PullRequestReactionBar } from "./PullRequestReactions";
59import { PullRequestConversationGhost } from "./PullRequestGhosts";59import { PullRequestConversationGhost } from "./PullRequestGhosts";
60import { pullRequestLabelColor } from "./pullRequestList.logic";
60import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic";61import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic";
6162
62/** One reviewer, however a host happens to have cased their login this time. */63/** One reviewer, however a host happens to have cased their login this time. */
...@@ -64,12 +65,6 @@ function reviewerKey(login: string): string {...@@ -64,12 +65,6 @@ function reviewerKey(login: string): string {
64 return login.toLowerCase();65 return login.toLowerCase();
65}66}
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
73/** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */68/** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */
74function CommentAuthor({ actor }: { actor: PullRequestActor | null }) {69function CommentAuthor({ actor }: { actor: PullRequestActor | null }) {
75 const login = actor?.login ?? "ghost";70 const login = actor?.login ?? "ghost";
...@@ -619,7 +614,7 @@ export function PullRequestSummaryTab({...@@ -619,7 +614,7 @@ export function PullRequestSummaryTab({
619 <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels">614 <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels">
620 <span className="flex min-w-0 flex-wrap items-center gap-1">615 <span className="flex min-w-0 flex-wrap items-center gap-1">
621 {detail.labels.map((label) => {616 {detail.labels.map((label) => {
622 const dot = labelDotColor(label.color);617 const dot = pullRequestLabelColor(label.color);
623 return (618 return (
624 <span619 <span
625 key={label.name}620 key={label.name}
apps/web/src/components/pullRequest/pullRequestList.logic.ts+71-3
...@@ -8,8 +8,10 @@ import {...@@ -8,8 +8,10 @@ import {
8 resolvePullRequestAuthorFilter,8 resolvePullRequestAuthorFilter,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import type {10import type {
11 PullRequestActor,
11 PullRequestDiffStat,12 PullRequestDiffStat,
12 PullRequestInvolvement,13 PullRequestInvolvement,
14 PullRequestLabel,
13 PullRequestListCursors,15 PullRequestListCursors,
14 PullRequestListFilters,16 PullRequestListFilters,
15 PullRequestListState,17 PullRequestListState,
...@@ -40,6 +42,16 @@ export interface PullRequestGroup<Entry extends PullRequestListEntry = PullReque...@@ -40,6 +42,16 @@ export interface PullRequestGroup<Entry extends PullRequestListEntry = PullReque
40 readonly entries: ReadonlyArray<Entry>;42 readonly entries: ReadonlyArray<Entry>;
41}43}
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
43/**55/**
44 * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than56 * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than
45 * one environment: two machines can both reach github.com signed in as different people, and a57 * 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 {...@@ -65,6 +77,59 @@ function normalize(value: string | null | undefined): string | null {
65 return trimmed.length > 0 ? trimmed : null;77 return trimmed.length > 0 ? trimmed : null;
66}78}
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
68/**133/**
69 * The signed-in login for the host a row came from, or null where none was given. Shared by134 * The signed-in login for the host a row came from, or null where none was given. Shared by
70 * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed.135 * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed.
...@@ -433,13 +498,16 @@ export function mergePullRequestDiffStats(...@@ -433,13 +498,16 @@ export function mergePullRequestDiffStats(
433 if (stats.length === 0) return previous;498 if (stats.length === 0) return previous;
434 const next = new Map(previous);499 const next = new Map(previous);
435 for (const stat of stats) {500 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 });
437 }505 }
438 return next;506 return next;
439}507}
440508
441/** A project id only names a project within its own environment, so the key carries both. */509/** A project id only names a project within its own environment, so the key carries both. */
442const diffStatKey = (row: {510export const pullRequestDiffStatKey = (row: {
443 readonly environmentId: string;511 readonly environmentId: string;
444 readonly projectId: string;512 readonly projectId: string;
445 readonly number: number;513 readonly number: number;
...@@ -792,6 +860,6 @@ export function withDiffStat<...@@ -792,6 +860,6 @@ export function withDiffStat<
792 statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>,860 statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>,
793): Entry {861): Entry {
794 if (entry.additions !== 0 || entry.deletions !== 0) return entry;862 if (entry.additions !== 0 || entry.deletions !== 0) return entry;
795 const stat = statsByRow.get(diffStatKey(entry));863 const stat = statsByRow.get(pullRequestDiffStatKey(entry));
796 return stat === undefined ? entry : { ...entry, ...stat };864 return stat === undefined ? entry : { ...entry, ...stat };
797}865}
apps/web/src/routes/_chat.pull-requests.tsx+177-17
...@@ -13,7 +13,11 @@ import type {...@@ -13,7 +13,11 @@ import type {
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { createFileRoute, useNavigate } from "@tanstack/react-router";14import { createFileRoute, useNavigate } from "@tanstack/react-router";
15import {15import {
16 ArrowDownUpIcon,
17 CalendarArrowDownIcon,
18 CalendarArrowUpIcon,
16 ChevronDownIcon,19 ChevronDownIcon,
20 ClockIcon,
17 EyeIcon,21 EyeIcon,
18 MonitorIcon,22 MonitorIcon,
19 ServerIcon,23 ServerIcon,
...@@ -23,6 +27,8 @@ import {...@@ -23,6 +27,8 @@ import {
23 LayersIcon,27 LayersIcon,
24 PenLineIcon,28 PenLineIcon,
25 LoaderIcon,29 LoaderIcon,
30 Maximize2Icon,
31 Minimize2Icon,
26 RefreshCwIcon,32 RefreshCwIcon,
27 SearchIcon,33 SearchIcon,
28} from "lucide-react";34} from "lucide-react";
...@@ -31,6 +37,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro...@@ -31,6 +37,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
31import {37import {
32 filterPullRequestsByInvolvement,38 filterPullRequestsByInvolvement,
33 findScopedProject,39 findScopedProject,
40 collectPullRequestListFacets,
34 groupPullRequestsByInvolvement,41 groupPullRequestsByInvolvement,
35 matchesPullRequestFilters,42 matchesPullRequestFilters,
36 matchesPullRequestQuery,43 matchesPullRequestQuery,
...@@ -38,6 +45,7 @@ import {...@@ -38,6 +45,7 @@ import {
38 narrowPullRequestsToFilters,45 narrowPullRequestsToFilters,
39 mergePullRequestDiffStats,46 mergePullRequestDiffStats,
40 partitionPullRequestsWithPriority,47 partitionPullRequestsWithPriority,
48 pullRequestDiffStatKey,
41 pullRequestEntryKey,49 pullRequestEntryKey,
42 pullRequestEntryViewer,50 pullRequestEntryViewer,
43 rankPullRequestMatches,51 rankPullRequestMatches,
...@@ -58,6 +66,7 @@ import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequ...@@ -58,6 +66,7 @@ import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequ
58import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";66import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
59import {67import {
60 PullRequestFiltersMenu,68 PullRequestFiltersMenu,
69 PullRequestFilterOptionIcon,
61 PullRequestSearchInput,70 PullRequestSearchInput,
62 pullRequestHostLabel,71 pullRequestHostLabel,
63 pullRequestProjectKey,72 pullRequestProjectKey,
...@@ -83,6 +92,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../...@@ -83,6 +92,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../
83import { SidebarInset } from "../components/ui/sidebar";92import { SidebarInset } from "../components/ui/sidebar";
84import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";93import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
85import { useLiveRefresh } from "../hooks/useLiveRefresh";94import { useLiveRefresh } from "../hooks/useLiveRefresh";
95import { toSortableTimestamp } from "../lib/threadSort";
86import {96import {
87 selectActiveRightPanelSurface,97 selectActiveRightPanelSurface,
88 selectSelectedRightPanelSurface,98 selectSelectedRightPanelSurface,
...@@ -136,8 +146,13 @@ export interface PullRequestsSearch {...@@ -136,8 +146,13 @@ export interface PullRequestsSearch {
136 readonly draft?: "only" | "hide";146 readonly draft?: "only" | "hide";
137 readonly review?: NonNullable<PullRequestListFilters["review"]>;147 readonly review?: NonNullable<PullRequestListFilters["review"]>;
138 readonly checks?: NonNullable<PullRequestListFilters["checks"]>;148 readonly checks?: NonNullable<PullRequestListFilters["checks"]>;
149 readonly author?: string;
150 readonly labels?: ReadonlyArray<string>;
151 readonly sort?: PullRequestListSort;
139}152}
140153
154type PullRequestListSort = "updated" | "newest" | "oldest" | "largest" | "smallest";
155
141// The state filters wear the same glyphs the rows do, so the two read as one vocabulary.156// The state filters wear the same glyphs the rows do, so the two read as one vocabulary.
142const INVOLVEMENT_TABS = [157const INVOLVEMENT_TABS = [
143 { value: "all", label: "All", Icon: LayersIcon },158 { value: "all", label: "All", Icon: LayersIcon },
...@@ -152,6 +167,14 @@ const STATE_TABS = [...@@ -152,6 +167,14 @@ const STATE_TABS = [
152 { value: "merged", label: "Merged", Icon: GitMergeIcon },167 { value: "merged", label: "Merged", Icon: GitMergeIcon },
153] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;168] 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
155/** Long enough that a keystroke does not become a request, short enough to feel answered. */178/** Long enough that a keystroke does not become a request, short enough to feel answered. */
156const SEARCH_DEBOUNCE_MS = 250;179const SEARCH_DEBOUNCE_MS = 250;
157/** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */180/** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */
...@@ -181,6 +204,26 @@ const EMPTY_PREVIEW_SESSIONS = {};...@@ -181,6 +204,26 @@ const EMPTY_PREVIEW_SESSIONS = {};
181const EMPTY_PREVIEW_DESKTOP_STATE = {};204const EMPTY_PREVIEW_DESKTOP_STATE = {};
182const EMPTY_TERMINAL_LABELS = new Map<string, string>();205const EMPTY_TERMINAL_LABELS = new Map<string, string>();
183const EMPTY_PENDING_SURFACES = new Set<string>();206const 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
185export const Route = createFileRoute("/_chat/pull-requests")({228export const Route = createFileRoute("/_chat/pull-requests")({
186 validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({229 validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({
...@@ -188,6 +231,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({...@@ -188,6 +231,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({
188 raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all",231 raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all",
189 state:232 state:
190 raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open",233 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 : {}),
191 ...(typeof raw.repository === "string" && raw.repository237 ...(typeof raw.repository === "string" && raw.repository
192 ? { repository: raw.repository.slice(0, 200) }238 ? { repository: raw.repository.slice(0, 200) }
193 : {}),239 : {}),
...@@ -216,12 +262,17 @@ export const Route = createFileRoute("/_chat/pull-requests")({...@@ -216,12 +262,17 @@ export const Route = createFileRoute("/_chat/pull-requests")({
216 ? { review: raw.review }262 ? { review: raw.review }
217 : {}),263 : {}),
218 ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}),264 ...(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),
219 }),269 }),
220 component: PullRequestsRouteView,270 component: PullRequestsRouteView,
221});271});
222272
223function PullRequestsRouteView() {273function PullRequestsRouteView() {
224 const search = Route.useSearch();274 const search = Route.useSearch();
275 const sort = search.sort ?? "updated";
225 const navigate = useNavigate({ from: Route.fullPath });276 const navigate = useNavigate({ from: Route.fullPath });
226 const { environments } = useEnvironments();277 const { environments } = useEnvironments();
227 // Every connected environment that has said it can list pull requests. Sorted, so the query278 // Every connected environment that has said it can list pull requests. Sorted, so the query
...@@ -416,6 +467,7 @@ function PullRequestsRouteView() {...@@ -416,6 +467,7 @@ function PullRequestsRouteView() {
416 return {467 return {
417 involvement: next.involvement ?? previous.involvement,468 involvement: next.involvement ?? previous.involvement,
418 state: next.state ?? previous.state,469 state: next.state ?? previous.state,
470 ...(next.sort && next.sort !== "updated" ? { sort: next.sort } : {}),
419 ...(next.repository ? { repository: next.repository } : {}),471 ...(next.repository ? { repository: next.repository } : {}),
420 ...(next.number ? { number: next.number } : {}),472 ...(next.number ? { number: next.number } : {}),
421 ...(next.projectId ? { projectId: next.projectId } : {}),473 ...(next.projectId ? { projectId: next.projectId } : {}),
...@@ -429,6 +481,8 @@ function PullRequestsRouteView() {...@@ -429,6 +481,8 @@ function PullRequestsRouteView() {
429 ...(next.draft ? { draft: next.draft } : {}),481 ...(next.draft ? { draft: next.draft } : {}),
430 ...(next.review ? { review: next.review } : {}),482 ...(next.review ? { review: next.review } : {}),
431 ...(next.checks ? { checks: next.checks } : {}),483 ...(next.checks ? { checks: next.checks } : {}),
484 ...(next.author ? { author: next.author } : {}),
485 ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}),
432 };486 };
433 },487 },
434 replace: true,488 replace: true,
...@@ -458,6 +512,7 @@ function PullRequestsRouteView() {...@@ -458,6 +512,7 @@ function PullRequestsRouteView() {
458 // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is512 // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is
459 // late but the page is not.513 // late but the page is not.
460 const typedQuery = (search.q ?? "").trim();514 const typedQuery = (search.q ?? "").trim();
515 const [filtersOpen, setFiltersOpen] = useState(false);
461 const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS);516 const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS);
462 const querySettled = typedQuery === sentQuery;517 const querySettled = typedQuery === sentQuery;
463 // What was typed, split into the qualifiers the hosts can act on and the words that are left.518 // What was typed, split into the qualifiers the hosts can act on and the words that are left.
...@@ -472,12 +527,14 @@ function PullRequestsRouteView() {...@@ -472,12 +527,14 @@ function PullRequestsRouteView() {
472 ...(search.draft ? { draft: search.draft } : {}),527 ...(search.draft ? { draft: search.draft } : {}),
473 ...(search.review ? { review: search.review } : {}),528 ...(search.review ? { review: search.review } : {}),
474 ...(search.checks ? { checks: search.checks } : {}),529 ...(search.checks ? { checks: search.checks } : {}),
530 ...(search.author ? { author: search.author } : {}),
531 ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}),
475 }),532 }),
476 [search.checks, search.draft, search.review],533 [search.author, search.checks, search.draft, search.labels, search.review],
477 );534 );
478 const menuFiltered = Object.keys(menuFilters).length > 0;535 const menuFiltered = Object.keys(menuFilters).length > 0;
479 // A typed qualifier wins over the menu's own answer for the same thing, since it is the more536 // 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.
481 const filters = useMemo(538 const filters = useMemo(
482 (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }),539 (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }),
483 [menuFilters, sentParsed.filters],540 [menuFilters, sentParsed.filters],
...@@ -548,7 +605,7 @@ function PullRequestsRouteView() {...@@ -548,7 +605,7 @@ function PullRequestsRouteView() {
548 [environmentQueries],605 [environmentQueries],
549 );606 );
550 // Page size is view state, not a URL concern: a shared link should open the first page.607 // 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") ?? ""}`;
552 const filterKey = `${scopeKey}:${sentQuery}`;609 const filterKey = `${scopeKey}:${sentQuery}`;
553 // Where the next slice carries on from, per repository within each environment, as that610 // Where the next slice carries on from, per repository within each environment, as that
554 // environment handed it back. Sending it is what makes a second page cost a second page rather611 // environment handed it back. Sending it is what makes a second page cost a second page rather
...@@ -660,6 +717,21 @@ function PullRequestsRouteView() {...@@ -660,6 +717,21 @@ function PullRequestsRouteView() {
660 ],717 ],
661 );718 );
662 const baselineQuery = usePullRequestList(baselineTargets);719 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);
663 // The priority groups' own reads. The feed below is paginated by recency, so an older authored735 // The priority groups' own reads. The feed below is paginated by recency, so an older authored
664 // or review-requested row can be missing from its first page; partitioned from these736 // or review-requested row can be missing from its first page; partitioned from these
665 // server-filtered reads instead, the priority view is complete up front and a continuation can737 // server-filtered reads instead, the priority view is complete up front and a continuation can
...@@ -720,6 +792,7 @@ function PullRequestsRouteView() {...@@ -720,6 +792,7 @@ function PullRequestsRouteView() {
720 }792 }
721 refreshList();793 refreshList();
722 baselineQuery.refresh();794 baselineQuery.refresh();
795 facetQuery.refresh();
723 authoredQuery.refresh();796 authoredQuery.refresh();
724 reviewingQuery.refresh();797 reviewingQuery.refresh();
725 statsQuery.refresh();798 statsQuery.refresh();
...@@ -982,6 +1055,17 @@ function PullRequestsRouteView() {...@@ -982,6 +1055,17 @@ function PullRequestsRouteView() {
9821055
983 const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS;1056 const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS;
984 const listErrors = baselineQuery.data?.errors ?? listData?.errors ?? [];1057 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
986 /** The hosts that narrowed the listing themselves, so their answer is not narrowed again. */1070 /** The hosts that narrowed the listing themselves, so their answer is not narrowed again. */
987 const searchingHosts = useMemo(1071 const searchingHosts = useMemo(
...@@ -1170,6 +1254,40 @@ function PullRequestsRouteView() {...@@ -1170,6 +1254,40 @@ function PullRequestsRouteView() {
1170 if (stats === null) return;1254 if (stats === null) return;
1171 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));1255 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));
1172 }, [statsQuery.stats]);1256 }, [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
1174 const linkedSelection = useMemo(1292 const linkedSelection = useMemo(
1175 () =>1293 () =>
...@@ -1347,6 +1465,7 @@ function PullRequestsRouteView() {...@@ -1347,6 +1465,7 @@ function PullRequestsRouteView() {
1347 onRefresh={() => void refreshFromHost()}1465 onRefresh={() => void refreshFromHost()}
1348 query={typedQuery}1466 query={typedQuery}
1349 filtered={1467 filtered={
1468 menuFiltered ||
1350 search.state !== "open" ||1469 search.state !== "open" ||
1351 search.involvement !== "all" ||1470 search.involvement !== "all" ||
1352 scopedProjectId !== undefined ||1471 scopedProjectId !== undefined ||
...@@ -1360,7 +1479,7 @@ function PullRequestsRouteView() {...@@ -1360,7 +1479,7 @@ function PullRequestsRouteView() {
1360 />1479 />
1361 ) : (1480 ) : (
1362 <div className="space-y-3">1481 <div className="space-y-3">
1363 {groups.map((group) => (1482 {displayGroups.map((group) => (
1364 <div key={group.key} className="space-y-0.5">1483 <div key={group.key} className="space-y-0.5">
1365 {group.label ? (1484 {group.label ? (
1366 <h2 className="px-3 pb-0.5 text-xs font-medium text-muted-foreground/70">1485 <h2 className="px-3 pb-0.5 text-xs font-medium text-muted-foreground/70">
...@@ -1370,10 +1489,7 @@ function PullRequestsRouteView() {...@@ -1370,10 +1489,7 @@ function PullRequestsRouteView() {
1370 {group.entries.map((entry) => (1489 {group.entries.map((entry) => (
1371 <PullRequestRow1490 <PullRequestRow
1372 key={pullRequestEntryKey(entry)}1491 key={pullRequestEntryKey(entry)}
1373 // A row whose host reported its line counts keeps them; one whose host left1492 entry={entry}
1374 // them for later takes whatever has arrived since, and draws without them
1375 // until it does.
1376 entry={withDiffStat(entry, statsByRow)}
1377 showProjectTitle1493 showProjectTitle
1378 showProvider={showProvider}1494 showProvider={showProvider}
1379 {...(capableEnvironments.length > 1 &&1495 {...(capableEnvironments.length > 1 &&
...@@ -1449,8 +1565,20 @@ function PullRequestsRouteView() {...@@ -1449,8 +1565,20 @@ function PullRequestsRouteView() {
1449 Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon,1565 Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon,
1450 })),1566 })),
1451 ];1567 ];
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 );
1452 const filtersMenu = (1579 const filtersMenu = (
1453 <PullRequestFiltersMenu1580 <PullRequestFiltersMenu
1581 onOpenChange={setFiltersOpen}
1454 state={search.state}1582 state={search.state}
1455 stateOptions={STATE_TABS}1583 stateOptions={STATE_TABS}
1456 onState={(state) => updateListScope({ state })}1584 onState={(state) => updateListScope({ state })}
...@@ -1459,8 +1587,16 @@ function PullRequestsRouteView() {...@@ -1459,8 +1587,16 @@ function PullRequestsRouteView() {
1459 onInvolvement={(involvement) => updateListScope({ involvement })}1587 onInvolvement={(involvement) => updateListScope({ involvement })}
1460 filters={menuFilters}1588 filters={menuFilters}
1461 onFilters={(next) =>1589 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 })
1463 }1597 }
1598 authorOptions={facets.authors}
1599 labelOptions={facets.labels}
1464 host={search.host}1600 host={search.host}
1465 hostOptions={hostMenuOptions}1601 hostOptions={hostMenuOptions}
1466 onHost={(host) => updateListScope({ host })}1602 onHost={(host) => updateListScope({ host })}
...@@ -1493,6 +1629,7 @@ function PullRequestsRouteView() {...@@ -1493,6 +1629,7 @@ function PullRequestsRouteView() {
1493 onState: (state: PullRequestListState) => updateListScope({ state }),1629 onState: (state: PullRequestListState) => updateListScope({ state }),
1494 onHost: (host: string | undefined) => updateListScope({ host }),1630 onHost: (host: string | undefined) => updateListScope({ host }),
1495 searchInput,1631 searchInput,
1632 sortMenu,
1496 filtersMenu,1633 filtersMenu,
1497 rightPanelControl:1634 rightPanelControl:
1498 // Footprint reserve while the panel is closed: the toggle itself stays1635 // Footprint reserve while the panel is closed: the toggle itself stays
...@@ -1625,12 +1762,18 @@ function PullRequestsRouteView() {...@@ -1625,12 +1762,18 @@ function PullRequestsRouteView() {
1625/** A compact stand-in for one pill group when the header is narrow. */1762/** A compact stand-in for one pill group when the header is narrow. */
1626function CompactFilterMenu<Value extends string>({1763function CompactFilterMenu<Value extends string>({
1627 label,1764 label,
1765 triggerIcon,
1766 triggerLabel,
1767 outlined = false,
1628 value,1768 value,
1629 options,1769 options,
1630 onChange,1770 onChange,
1631 className,1771 className,
1632}: {1772}: {
1633 label: string;1773 label: string;
1774 triggerIcon?: ReactNode;
1775 triggerLabel?: string;
1776 outlined?: boolean;
1634 value: Value;1777 value: Value;
1635 options: ReadonlyArray<PullRequestFilterOption<Value>>;1778 options: ReadonlyArray<PullRequestFilterOption<Value>>;
1636 onChange: (value: Value) => void;1779 onChange: (value: Value) => void;
...@@ -1641,14 +1784,28 @@ function CompactFilterMenu<Value extends string>({...@@ -1641,14 +1784,28 @@ function CompactFilterMenu<Value extends string>({
1641 return (1784 return (
1642 <Menu>1785 <Menu>
1643 <MenuTrigger1786 <MenuTrigger
1644 aria-label={label}1787 aria-label={triggerLabel ? `${label}: ${current.label}` : label}
1645 className={cn(1788 render={outlined ? <Button variant="outline" /> : undefined}
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",1789 className={
1647 className,1790 outlined
1648 )}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 }
1649 >1797 >
1650 <span className="truncate">{current.label}</span>1798 {triggerLabel ? (
1651 <ChevronDownIcon aria-hidden className="size-3 shrink-0 text-muted-foreground/70" />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 )}
1652 </MenuTrigger>1809 </MenuTrigger>
1653 <MenuPopup align="start" side="bottom" className="min-w-40">1810 <MenuPopup align="start" side="bottom" className="min-w-40">
1654 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>1811 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
...@@ -1661,7 +1818,7 @@ function CompactFilterMenu<Value extends string>({...@@ -1661,7 +1818,7 @@ function CompactFilterMenu<Value extends string>({
1661 className="data-disabled:pointer-events-auto"1818 className="data-disabled:pointer-events-auto"
1662 >1819 >
1663 <span className="flex min-w-0 items-center gap-2">1820 <span className="flex min-w-0 items-center gap-2">
1664 <option.Icon aria-hidden className="size-3.5" />1821 <PullRequestFilterOptionIcon option={option} />
1665 {option.label}1822 {option.label}
1666 </span>1823 </span>
1667 </MenuRadioItem>1824 </MenuRadioItem>
...@@ -1769,6 +1926,7 @@ function PullRequestsColumn({...@@ -1769,6 +1926,7 @@ function PullRequestsColumn({
1769 onState,1926 onState,
1770 onHost,1927 onHost,
1771 searchInput,1928 searchInput,
1929 sortMenu,
1772 filtersMenu,1930 filtersMenu,
1773 rightPanelControl,1931 rightPanelControl,
1774 titlebarControls,1932 titlebarControls,
...@@ -1786,6 +1944,7 @@ function PullRequestsColumn({...@@ -1786,6 +1944,7 @@ function PullRequestsColumn({
1786 onState: (state: PullRequestListState) => void;1944 onState: (state: PullRequestListState) => void;
1787 onHost: (host: string | undefined) => void;1945 onHost: (host: string | undefined) => void;
1788 searchInput: ReactNode;1946 searchInput: ReactNode;
1947 sortMenu: ReactNode;
1789 filtersMenu: ReactNode;1948 filtersMenu: ReactNode;
1790 rightPanelControl: ReactNode;1949 rightPanelControl: ReactNode;
1791 titlebarControls: ReactNode;1950 titlebarControls: ReactNode;
...@@ -1934,6 +2093,7 @@ function PullRequestsColumn({...@@ -1934,6 +2093,7 @@ function PullRequestsColumn({
1934 <div className="flex flex-col gap-3">2093 <div className="flex flex-col gap-3">
1935 <div ref={inFlowSearchRef} className="flex items-center gap-2">2094 <div ref={inFlowSearchRef} className="flex items-center gap-2">
1936 {searchInput}2095 {searchInput}
2096 {sortMenu}
1937 {filtersMenu}2097 {filtersMenu}
1938 {!condensed ? (2098 {!condensed ? (
1939 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />2099 <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:...@@ -41,6 +41,9 @@ T3 Code works with the platforms your team already uses:
4141
42- See if your current branch already has an open PR/MR42- See if your current branch already has an open PR/MR
43- Open several reviews from the **Pull requests** page as tabs in the right panel43- 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
44- While working in a thread, open linked reviews in the same compact right-panel tabs without47- While working in a thread, open linked reviews in the same compact right-panel tabs without
45 leaving the conversation48 leaving the conversation
46- Open the review directly in your browser with one click49- Open the review directly in your browser with one click