Unified · Split
| @@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode { · 33 unmodified lines | ||
| 34 | 34 | if (!isValidElement(child)) continue; |
| 35 | 35 | const props = child.props as { readonly children?: ReactNode; readonly label?: string }; |
| 36 | 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 | 40 | const nested = findLabeledGroup(props.children, label); |
| 40 | 41 | if (nested !== undefined) return nested; |
| @@ -126,7 +127,7 @@ describe("pull request filters menu", () => { · 85 unmodified lines | ||
| 126 | 127 | projectEnvironmentId: environmentId, |
| 127 | 128 | onProject, |
| 128 | 129 | }); |
| 129 | − const radioGroup = findValueChange(view); | |
| 130 | + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); | |
| 130 | 131 | expect(radioGroup).toBeDefined(); |
| 131 | 132 | |
| 132 | 133 | radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId })); |
| @@ -156,7 +157,7 @@ describe("pull request filters menu", () => { · 23 unmodified lines | ||
| 156 | 157 | ], |
| 157 | 158 | onProject, |
| 158 | 159 | }); |
| 159 | − const radioGroup = findValueChange(view); | |
| 160 | + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); | |
| 160 | 161 | expect(radioGroup).toBeDefined(); |
| 161 | 162 | |
| 162 | 163 | radioGroup?.props.onValueChange( |
| @@ -18,10 +18,11 @@ import { · 17 unmodified lines | ||
| 18 | 18 | ListFilterIcon, |
| 19 | 19 | LoaderIcon, |
| 20 | 20 | SearchIcon, |
| 21 | + TagIcon, | |
| 22 | + UserRoundIcon, | |
| 21 | 23 | } from "lucide-react"; |
| 22 | −import type { ElementType } from "react"; | |
| 24 | +import { type ElementType, useState } from "react"; | |
| 23 | 25 | |
| 24 | −import { cn } from "~/lib/utils"; | |
| 25 | 26 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 26 | 27 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 27 | 28 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; |
| @@ -29,27 +30,56 @@ import { Button } from "../ui/button"; · 1 unmodified line | ||
| 29 | 30 | |
| 30 | 31 | import { |
| 31 | 32 | Menu, |
| 33 | + MenuCheckboxItem, | |
| 32 | 34 | MenuGroupLabel, |
| 35 | + MenuItem, | |
| 33 | 36 | MenuPopup, |
| 34 | 37 | MenuRadioGroup, |
| 35 | 38 | MenuRadioItem, |
| 36 | 39 | MenuSeparator, |
| 40 | + MenuSub, | |
| 41 | + MenuSubPopup, | |
| 42 | + MenuSubTrigger, | |
| 37 | 43 | MenuTrigger, |
| 38 | 44 | } from "../ui/menu"; |
| 39 | 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 46 | +import { | |
| 47 | + pullRequestLabelColor, | |
| 48 | + type PullRequestAuthorFacet, | |
| 49 | + type PullRequestLabelFacet, | |
| 50 | +} from "./pullRequestList.logic"; | |
| 51 | +import { PullRequestActorAvatar } from "./pullRequestPresentation"; | |
| 40 | 52 | |
| 41 | 53 | export interface PullRequestFilterOption<Value extends string> { |
| 42 | 54 | readonly value: Value; |
| 43 | 55 | 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. */ | |
| 48 | 57 | readonly Icon: ElementType<{ className?: string }>; |
| 58 | + readonly favicon?: { | |
| 59 | + readonly environmentId: EnvironmentId; | |
| 60 | + readonly cwd: string; | |
| 61 | + }; | |
| 49 | 62 | /** Why it cannot be chosen, carried onto the item as its title. */ |
| 50 | 63 | readonly unavailable?: string | undefined; |
| 51 | 64 | } |
| 52 | 65 | |
| 66 | +export function PullRequestFilterOptionIcon<Value extends string>({ | |
| 67 | + option, | |
| 68 | +}: { | |
| 69 | + option: PullRequestFilterOption<Value>; | |
| 70 | +}) { | |
| 71 | + return option.favicon ? ( | |
| 72 | + <ProjectFavicon | |
| 73 | + environmentId={option.favicon.environmentId} | |
| 74 | + cwd={option.favicon.cwd} | |
| 75 | + fallbackIcon={FolderGit2Icon} | |
| 76 | + className="size-3.5" | |
| 77 | + /> | |
| 78 | + ) : ( | |
| 79 | + <option.Icon aria-hidden className="size-3.5" /> | |
| 80 | + ); | |
| 81 | +} | |
| 82 | + | |
| 53 | 83 | export interface PullRequestExpectedHost { |
| 54 | 84 | readonly host: string; |
| 55 | 85 | readonly kind: SourceControlProviderKind; |
| @@ -98,10 +128,8 @@ export function PullRequestSearchInput({ · 42 unmodified lines | ||
| 98 | 128 | } |
| 99 | 129 | |
| 100 | 130 | /** |
| 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. | |
| 105 | 133 | */ |
| 106 | 134 | const ALL_PROJECTS_VALUE = "all"; |
| 107 | 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>({ · 61 unmodified lines | ||
| 169 | 197 | disabled={option.unavailable !== undefined} |
| 170 | 198 | > |
| 171 | 199 | <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} | |
| 174 | 203 | </span> |
| 175 | 204 | </MenuRadioItem> |
| 176 | 205 | ); |
| @@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup<Value extends string>({ · 11 unmodified lines | ||
| 188 | 217 | ); |
| 189 | 218 | } |
| 190 | 219 | |
| 220 | +function PullRequestFilterRadioSubmenu<Value extends string>({ | |
| 221 | + label, | |
| 222 | + value, | |
| 223 | + options, | |
| 224 | + onChange, | |
| 225 | +}: { | |
| 226 | + label: string; | |
| 227 | + value: Value; | |
| 228 | + options: ReadonlyArray<PullRequestFilterOption<Value>>; | |
| 229 | + onChange: (value: Value) => void; | |
| 230 | +}) { | |
| 231 | + const current = options.find((option) => option.value === value) ?? options[0]; | |
| 232 | + if (!current) return null; | |
| 233 | + return ( | |
| 234 | + <MenuSub> | |
| 235 | + <MenuSubTrigger> | |
| 236 | + <PullRequestFilterOptionIcon option={current} /> | |
| 237 | + <span className="flex-1">{label}</span> | |
| 238 | + <span className="min-w-0 max-w-32 truncate text-xs text-muted-foreground"> | |
| 239 | + {current.label} | |
| 240 | + </span> | |
| 241 | + </MenuSubTrigger> | |
| 242 | + <MenuSubPopup className="min-w-56"> | |
| 243 | + <PullRequestFilterRadioGroup | |
| 244 | + label={label} | |
| 245 | + value={value} | |
| 246 | + options={options} | |
| 247 | + onChange={onChange} | |
| 248 | + /> | |
| 249 | + </MenuSubPopup> | |
| 250 | + </MenuSub> | |
| 251 | + ); | |
| 252 | +} | |
| 253 | + | |
| 254 | +function PullRequestAuthorFilter({ | |
| 255 | + value, | |
| 256 | + options, | |
| 257 | + onChange, | |
| 258 | +}: { | |
| 259 | + value: string | undefined; | |
| 260 | + options: ReadonlyArray<PullRequestAuthorFacet>; | |
| 261 | + onChange: (author: string | undefined) => void; | |
| 262 | +}) { | |
| 263 | + const [query, setQuery] = useState(""); | |
| 264 | + const needle = query.trim().toLowerCase(); | |
| 265 | + const login = value?.toLowerCase() ?? ""; | |
| 266 | + const selected = options.find((option) => option.actor.login.toLowerCase() === login); | |
| 267 | + const visible = [ | |
| 268 | + ...(selected ? [selected] : []), | |
| 269 | + ...options.filter( | |
| 270 | + (option) => | |
| 271 | + option !== selected && | |
| 272 | + (needle.length === 0 || | |
| 273 | + option.actor.login.toLowerCase().includes(needle) || | |
| 274 | + option.actor.name?.toLowerCase().includes(needle)), | |
| 275 | + ), | |
| 276 | + ].slice(0, 10); | |
| 277 | + const select = (next: string) => next.toLowerCase() !== login && onChange(next || undefined); | |
| 278 | + return ( | |
| 279 | + <MenuSub> | |
| 280 | + <MenuSubTrigger> | |
| 281 | + <UserRoundIcon aria-hidden className="size-3.5" /> | |
| 282 | + <span className="flex-1">Author</span> | |
| 283 | + <span className="min-w-0 max-w-32 truncate text-xs text-muted-foreground"> | |
| 284 | + {value ?? "Anyone"} | |
| 285 | + </span> | |
| 286 | + </MenuSubTrigger> | |
| 287 | + <MenuSubPopup className="w-80"> | |
| 288 | + <div className="p-1 pb-2"> | |
| 289 | + <InputGroup> | |
| 290 | + <InputGroupAddon> | |
| 291 | + <SearchIcon aria-hidden /> | |
| 292 | + </InputGroupAddon> | |
| 293 | + <InputGroupInput | |
| 294 | + autoFocus | |
| 295 | + size="compact" | |
| 296 | + value={query} | |
| 297 | + onChange={(event) => setQuery(event.currentTarget.value)} | |
| 298 | + onKeyDown={(event) => { | |
| 299 | + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); | |
| 300 | + }} | |
| 301 | + placeholder="Search authors" | |
| 302 | + aria-label="Search authors" | |
| 303 | + /> | |
| 304 | + </InputGroup> | |
| 305 | + </div> | |
| 306 | + <MenuRadioGroup value={selected?.actor.login ?? value ?? ""} onValueChange={select}> | |
| 307 | + <MenuRadioItem value=""> | |
| 308 | + <span className="flex min-w-0 items-center gap-2"> | |
| 309 | + <LayersIcon aria-hidden className="size-3.5" /> | |
| 310 | + Anyone | |
| 311 | + </span> | |
| 312 | + </MenuRadioItem> | |
| 313 | + {visible.map((option) => ( | |
| 314 | + <MenuRadioItem key={option.actor.login.toLowerCase()} value={option.actor.login}> | |
| 315 | + <span className="flex min-w-0 items-center gap-2"> | |
| 316 | + <PullRequestActorAvatar actor={option.actor} /> | |
| 317 | + <span className="min-w-0 flex-1 truncate">{option.actor.login}</span> | |
| 318 | + <span className="shrink-0 text-xs text-muted-foreground tabular-nums"> | |
| 319 | + {option.mergedCount} merges loaded | |
| 320 | + </span> | |
| 321 | + </span> | |
| 322 | + </MenuRadioItem> | |
| 323 | + ))} | |
| 324 | + {visible.length === 0 ? <MenuItem disabled>No authors found</MenuItem> : null} | |
| 325 | + </MenuRadioGroup> | |
| 326 | + </MenuSubPopup> | |
| 327 | + </MenuSub> | |
| 328 | + ); | |
| 329 | +} | |
| 330 | + | |
| 331 | +function PullRequestLabelFilter({ | |
| 332 | + value, | |
| 333 | + options, | |
| 334 | + onChange, | |
| 335 | +}: { | |
| 336 | + value: ReadonlyArray<string>; | |
| 337 | + options: ReadonlyArray<PullRequestLabelFacet>; | |
| 338 | + onChange: (labels: ReadonlyArray<string>) => void; | |
| 339 | +}) { | |
| 340 | + const selected = new Set(value.map((name) => name.toLowerCase())); | |
| 341 | + const visible = [ | |
| 342 | + ...value | |
| 343 | + .filter((name) => !options.some((option) => option.name.toLowerCase() === name.toLowerCase())) | |
| 344 | + .map((name) => ({ name, color: null, count: 0 })), | |
| 345 | + ...options, | |
| 346 | + ]; | |
| 347 | + return ( | |
| 348 | + <MenuSub> | |
| 349 | + <MenuSubTrigger> | |
| 350 | + <TagIcon aria-hidden className="size-3.5" /> | |
| 351 | + <span className="flex-1">Labels</span> | |
| 352 | + <span className="text-xs text-muted-foreground"> | |
| 353 | + {value.length === 0 ? "Any" : `${value.length} selected`} | |
| 354 | + </span> | |
| 355 | + </MenuSubTrigger> | |
| 356 | + <MenuSubPopup className="w-72"> | |
| 357 | + {visible.length === 0 ? ( | |
| 358 | + <MenuItem disabled>No labels in this view</MenuItem> | |
| 359 | + ) : ( | |
| 360 | + visible.map((option) => { | |
| 361 | + const key = option.name.toLowerCase(); | |
| 362 | + const checked = selected.has(key); | |
| 363 | + const dot = pullRequestLabelColor(option.color); | |
| 364 | + return ( | |
| 365 | + <MenuCheckboxItem | |
| 366 | + key={key} | |
| 367 | + className="grid-cols-[1rem_minmax(0,1fr)]" | |
| 368 | + checked={checked} | |
| 369 | + onCheckedChange={(next) => | |
| 370 | + onChange( | |
| 371 | + next | |
| 372 | + ? [...value, option.name] | |
| 373 | + : value.filter((name) => name.toLowerCase() !== option.name.toLowerCase()), | |
| 374 | + ) | |
| 375 | + } | |
| 376 | + > | |
| 377 | + <span className="flex min-w-0 items-center gap-2"> | |
| 378 | + <span | |
| 379 | + aria-hidden | |
| 380 | + className="size-2.5 shrink-0 rounded-full bg-muted-foreground" | |
| 381 | + {...(dot ? { style: { backgroundColor: dot } } : {})} | |
| 382 | + /> | |
| 383 | + <span className="min-w-0 flex-1 truncate">{option.name}</span> | |
| 384 | + <span className="shrink-0 text-xs text-muted-foreground tabular-nums"> | |
| 385 | + {option.count} | |
| 386 | + </span> | |
| 387 | + </span> | |
| 388 | + </MenuCheckboxItem> | |
| 389 | + ); | |
| 390 | + }) | |
| 391 | + )} | |
| 392 | + </MenuSubPopup> | |
| 393 | + </MenuSub> | |
| 394 | + ); | |
| 395 | +} | |
| 396 | + | |
| 191 | 397 | export function PullRequestFiltersMenu({ |
| 398 | + onOpenChange, | |
| 192 | 399 | state, |
| 193 | 400 | stateOptions, |
| 194 | 401 | onState, |
| @@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({ · 2 unmodified lines | ||
| 197 | 404 | onInvolvement, |
| 198 | 405 | filters, |
| 199 | 406 | onFilters, |
| 407 | + authorOptions = [], | |
| 408 | + labelOptions = [], | |
| 200 | 409 | host, |
| 201 | 410 | hostOptions, |
| 202 | 411 | onHost, |
| @@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({ · 6 unmodified lines | ||
| 209 | 418 | unavailable, |
| 210 | 419 | onProject, |
| 211 | 420 | }: { |
| 421 | + onOpenChange?: (open: boolean) => void; | |
| 212 | 422 | state: PullRequestListState; |
| 213 | 423 | stateOptions: ReadonlyArray<PullRequestFilterOption<PullRequestListState>>; |
| 214 | 424 | onState: (state: PullRequestListState) => void; |
| @@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({ · 3 unmodified lines | ||
| 218 | 428 | /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */ |
| 219 | 429 | filters: PullRequestListFilters; |
| 220 | 430 | onFilters: (filters: PullRequestListFilters) => void; |
| 431 | + authorOptions?: ReadonlyArray<PullRequestAuthorFacet>; | |
| 432 | + labelOptions?: ReadonlyArray<PullRequestLabelFacet>; | |
| 221 | 433 | host: string | undefined; |
| 222 | 434 | /** |
| 223 | 435 | * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real |
| @@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({ · 30 unmodified lines | ||
| 254 | 466 | /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ |
| 255 | 467 | onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; |
| 256 | 468 | }) { |
| 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 | + ]; | |
| 274 | 514 | return ( |
| 275 | − <Menu> | |
| 515 | + <Menu onOpenChange={onOpenChange}> | |
| 276 | 516 | <MenuTrigger |
| 277 | 517 | render={ |
| 278 | 518 | <Button |
| 279 | − className={cn("relative", filtered && "[--control-icon-color:currentColor]")} | |
| 280 | − size="icon" | |
| 519 | + className={filterCount > 0 ? "[--control-icon-color:currentColor]" : undefined} | |
| 281 | 520 | variant="outline" |
| 282 | − aria-label="Filter pull requests" | |
| 283 | 521 | /> |
| 284 | 522 | } |
| 285 | 523 | > |
| 286 | 524 | <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> | |
| 292 | 530 | ) : null} |
| 293 | 531 | </MenuTrigger> |
| 294 | − <MenuPopup align="end" side="bottom" className="min-w-56"> | |
| 295 | − <PullRequestFilterRadioGroup | |
| 532 | + <MenuPopup align="end" side="bottom" className="w-56"> | |
| 533 | + <PullRequestFilterRadioSubmenu | |
| 296 | 534 | label="State" |
| 297 | 535 | value={state} |
| 298 | 536 | options={stateOptions} |
| 299 | 537 | onChange={onState} |
| 300 | 538 | /> |
| 301 | − <MenuSeparator /> | |
| 302 | − <PullRequestFilterRadioGroup | |
| 539 | + <PullRequestFilterRadioSubmenu | |
| 303 | 540 | label="Involvement" |
| 304 | 541 | value={involvement} |
| 305 | 542 | options={involvementOptions} |
| 306 | 543 | onChange={onInvolvement} |
| 307 | 544 | /> |
| 308 | 545 | <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 | |
| 310 | 561 | label="Draft" |
| 311 | 562 | value={filters.draft ?? UNFILTERED_VALUE} |
| 312 | 563 | options={DRAFT_OPTIONS} |
| 313 | − onChange={(next) => onFilters(withFilter("draft", next))} | |
| 564 | + onChange={(draft) => updateFilter("draft", draft)} | |
| 314 | 565 | /> |
| 315 | − <MenuSeparator /> | |
| 316 | − <PullRequestFilterRadioGroup | |
| 566 | + <PullRequestFilterRadioSubmenu | |
| 317 | 567 | label="Review" |
| 318 | 568 | value={filters.review ?? UNFILTERED_VALUE} |
| 319 | 569 | options={REVIEW_OPTIONS} |
| 320 | − onChange={(next) => onFilters(withFilter("review", next))} | |
| 570 | + onChange={(review) => updateFilter("review", review)} | |
| 321 | 571 | /> |
| 322 | − <MenuSeparator /> | |
| 323 | − <PullRequestFilterRadioGroup | |
| 572 | + <PullRequestFilterRadioSubmenu | |
| 324 | 573 | label="Checks" |
| 325 | 574 | value={filters.checks ?? UNFILTERED_VALUE} |
| 326 | 575 | options={CHECKS_OPTIONS} |
| 327 | − onChange={(next) => onFilters(withFilter("checks", next))} | |
| 576 | + onChange={(checks) => updateFilter("checks", checks)} | |
| 328 | 577 | /> |
| 329 | 578 | {hostOptions.length > 2 ? ( |
| 330 | 579 | <> |
| 331 | 580 | <MenuSeparator /> |
| 332 | − <PullRequestFilterRadioGroup | |
| 581 | + <PullRequestFilterRadioSubmenu | |
| 333 | 582 | label="Host" |
| 334 | 583 | value={host ?? ALL_HOSTS_VALUE} |
| 335 | 584 | options={hostOptions} |
| @@ -340,7 +589,7 @@ export function PullRequestFiltersMenu({ · 4 unmodified lines | ||
| 340 | 589 | {serverOptions.length > 2 ? ( |
| 341 | 590 | <> |
| 342 | 591 | <MenuSeparator /> |
| 343 | − <PullRequestFilterRadioGroup | |
| 592 | + <PullRequestFilterRadioSubmenu | |
| 344 | 593 | label="Server" |
| 345 | 594 | value={server ?? ALL_SERVERS_VALUE} |
| 346 | 595 | options={serverOptions} |
| @@ -351,79 +600,16 @@ export function PullRequestFiltersMenu({ · 4 unmodified lines | ||
| 351 | 600 | </> |
| 352 | 601 | ) : null} |
| 353 | 602 | <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) => { | |
| 367 | 608 | 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); | |
| 374 | 611 | }} |
| 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 | + /> | |
| 427 | 613 | </MenuPopup> |
| 428 | 614 | </Menu> |
| 429 | 615 | ); |
| @@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 9 | 9 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 10 | −import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | |
| 10 | +import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | |
| 11 | 11 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; |
| 12 | 12 | import { |
| 13 | 13 | PullRequestActorLabel, |
| @@ -16,6 +16,23 @@ import { · 2 unmodified lines | ||
| 16 | 16 | PullRequestStateGlyph, |
| 17 | 17 | } from "./pullRequestPresentation"; |
| 18 | 18 | |
| 19 | +function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) { | |
| 20 | + const label = labels[0]; | |
| 21 | + if (!label) return null; | |
| 22 | + const dot = pullRequestLabelColor(label.color); | |
| 23 | + return ( | |
| 24 | + <span className="inline-flex max-w-40 min-w-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 py-0 pl-1 pr-1.5 text-[10px] leading-3.5 text-muted-foreground"> | |
| 25 | + <span | |
| 26 | + aria-hidden | |
| 27 | + className="size-2 shrink-0 rounded-full bg-muted-foreground" | |
| 28 | + {...(dot ? { style: { backgroundColor: dot } } : {})} | |
| 29 | + /> | |
| 30 | + <span className="truncate">{label.name}</span> | |
| 31 | + {labels.length > 1 ? <span className="shrink-0">+{labels.length - 1}</span> : null} | |
| 32 | + </span> | |
| 33 | + ); | |
| 34 | +} | |
| 35 | + | |
| 19 | 36 | function PullRequestRowImpl({ |
| 20 | 37 | entry, |
| 21 | 38 | selected, |
| @@ -118,6 +135,7 @@ function PullRequestRowImpl({ · 96 unmodified lines | ||
| 118 | 135 | className="min-w-4 max-w-40" |
| 119 | 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 | 139 | {/* Only a verdict somebody has actually given: "review required" is the absence of |
| 122 | 140 | one, and saying so on every unreviewed row would say nothing. */} |
| 123 | 141 | {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( |
| @@ -57,6 +57,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; · 56 unmodified lines | ||
| 57 | 57 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; |
| 58 | 58 | import { PullRequestReactionBar } from "./PullRequestReactions"; |
| 59 | 59 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; |
| 60 | +import { pullRequestLabelColor } from "./pullRequestList.logic"; | |
| 60 | 61 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; |
| 61 | 62 | |
| 62 | 63 | /** One reviewer, however a host happens to have cased their login this time. */ |
| @@ -64,12 +65,6 @@ function reviewerKey(login: string): string { · 1 unmodified line | ||
| 64 | 65 | return login.toLowerCase(); |
| 65 | 66 | } |
| 66 | 67 | |
| 67 | −/** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ | |
| 68 | −function labelDotColor(color: string | null): string | null { | |
| 69 | − const hex = color?.trim().replace(/^#/, "") ?? ""; | |
| 70 | − return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; | |
| 71 | −} | |
| 72 | − | |
| 73 | 68 | /** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */ |
| 74 | 69 | function CommentAuthor({ actor }: { actor: PullRequestActor | null }) { |
| 75 | 70 | const login = actor?.login ?? "ghost"; |
| @@ -619,7 +614,7 @@ export function PullRequestSummaryTab({ · 543 unmodified lines | ||
| 619 | 614 | <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels"> |
| 620 | 615 | <span className="flex min-w-0 flex-wrap items-center gap-1"> |
| 621 | 616 | {detail.labels.map((label) => { |
| 622 | − const dot = labelDotColor(label.color); | |
| 617 | + const dot = pullRequestLabelColor(label.color); | |
| 623 | 618 | return ( |
| 624 | 619 | <span |
| 625 | 620 | key={label.name} |
| @@ -8,8 +8,10 @@ import { · 7 unmodified lines | ||
| 8 | 8 | resolvePullRequestAuthorFilter, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import type { |
| 11 | + PullRequestActor, | |
| 11 | 12 | PullRequestDiffStat, |
| 12 | 13 | PullRequestInvolvement, |
| 14 | + PullRequestLabel, | |
| 13 | 15 | PullRequestListCursors, |
| 14 | 16 | PullRequestListFilters, |
| 15 | 17 | PullRequestListState, |
| @@ -40,6 +42,16 @@ export interface PullRequestGroup<Entry extends PullRequestListEntry = PullReque · 24 unmodified lines | ||
| 40 | 42 | readonly entries: ReadonlyArray<Entry>; |
| 41 | 43 | } |
| 42 | 44 | |
| 45 | +export interface PullRequestAuthorFacet { | |
| 46 | + readonly actor: PullRequestActor; | |
| 47 | + readonly count: number; | |
| 48 | + readonly mergedCount: number; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export interface PullRequestLabelFacet extends PullRequestLabel { | |
| 52 | + readonly count: number; | |
| 53 | +} | |
| 54 | + | |
| 43 | 55 | /** |
| 44 | 56 | * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than |
| 45 | 57 | * one environment: two machines can both reach github.com signed in as different people, and a |
| @@ -65,6 +77,59 @@ function normalize(value: string | null | undefined): string | null { · 19 unmodified lines | ||
| 65 | 77 | return trimmed.length > 0 ? trimmed : null; |
| 66 | 78 | } |
| 67 | 79 | |
| 80 | +export function pullRequestLabelColor(color: string | null): string | null { | |
| 81 | + const hex = color?.trim().replace(/^#/, "") ?? ""; | |
| 82 | + return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export function collectPullRequestListFacets( | |
| 86 | + entries: ReadonlyArray<PullRequestListEntry>, | |
| 87 | + state: PullRequestListState, | |
| 88 | +) { | |
| 89 | + const authors = new Map<string, PullRequestAuthorFacet>(); | |
| 90 | + const labels = new Map<string, PullRequestLabelFacet>(); | |
| 91 | + const uniqueEntries = new Map(entries.map((entry) => [pullRequestEntryKey(entry), entry])); | |
| 92 | + for (const entry of uniqueEntries.values()) { | |
| 93 | + const inState = state === "all" || entry.state === state; | |
| 94 | + if (entry.author !== null) { | |
| 95 | + const key = normalize(entry.author.login); | |
| 96 | + if (key !== null) { | |
| 97 | + const held = authors.get(key); | |
| 98 | + authors.set(key, { | |
| 99 | + actor: held?.actor ?? entry.author, | |
| 100 | + count: (held?.count ?? 0) + Number(inState), | |
| 101 | + mergedCount: (held?.mergedCount ?? 0) + Number(entry.state === "merged"), | |
| 102 | + }); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + if (!inState) continue; | |
| 106 | + for (const label of entry.labels) { | |
| 107 | + const key = normalize(label.name); | |
| 108 | + if (key === null) continue; | |
| 109 | + const held = labels.get(key); | |
| 110 | + labels.set(key, { | |
| 111 | + ...label, | |
| 112 | + name: held?.name ?? label.name, | |
| 113 | + color: held?.color ?? label.color, | |
| 114 | + count: (held?.count ?? 0) + 1, | |
| 115 | + }); | |
| 116 | + } | |
| 117 | + } | |
| 118 | + return { | |
| 119 | + authors: [...authors.values()] | |
| 120 | + .filter((author) => author.count > 0) | |
| 121 | + .toSorted( | |
| 122 | + (left, right) => | |
| 123 | + right.mergedCount - left.mergedCount || | |
| 124 | + right.count - left.count || | |
| 125 | + left.actor.login.localeCompare(right.actor.login), | |
| 126 | + ), | |
| 127 | + labels: [...labels.values()].toSorted( | |
| 128 | + (left, right) => right.count - left.count || left.name.localeCompare(right.name), | |
| 129 | + ), | |
| 130 | + }; | |
| 131 | +} | |
| 132 | + | |
| 68 | 133 | /** |
| 69 | 134 | * The signed-in login for the host a row came from, or null where none was given. Shared by |
| 70 | 135 | * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed. |
| @@ -433,13 +498,16 @@ export function mergePullRequestDiffStats( · 362 unmodified lines | ||
| 433 | 498 | if (stats.length === 0) return previous; |
| 434 | 499 | const next = new Map(previous); |
| 435 | 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 | 506 | return next; |
| 439 | 507 | } |
| 440 | 508 | |
| 441 | 509 | /** A project id only names a project within its own environment, so the key carries both. */ |
| 442 | −const diffStatKey = (row: { | |
| 510 | +export const pullRequestDiffStatKey = (row: { | |
| 443 | 511 | readonly environmentId: string; |
| 444 | 512 | readonly projectId: string; |
| 445 | 513 | readonly number: number; |
| @@ -792,6 +860,6 @@ export function withDiffStat< · 346 unmodified lines | ||
| 792 | 860 | statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>, |
| 793 | 861 | ): Entry { |
| 794 | 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 | 864 | return stat === undefined ? entry : { ...entry, ...stat }; |
| 797 | 865 | } |
| @@ -13,7 +13,11 @@ import type { · 12 unmodified lines | ||
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | 14 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 15 | 15 | import { |
| 16 | + ArrowDownUpIcon, | |
| 17 | + CalendarArrowDownIcon, | |
| 18 | + CalendarArrowUpIcon, | |
| 16 | 19 | ChevronDownIcon, |
| 20 | + ClockIcon, | |
| 17 | 21 | EyeIcon, |
| 18 | 22 | MonitorIcon, |
| 19 | 23 | ServerIcon, |
| @@ -23,6 +27,8 @@ import { · 3 unmodified lines | ||
| 23 | 27 | LayersIcon, |
| 24 | 28 | PenLineIcon, |
| 25 | 29 | LoaderIcon, |
| 30 | + Maximize2Icon, | |
| 31 | + Minimize2Icon, | |
| 26 | 32 | RefreshCwIcon, |
| 27 | 33 | SearchIcon, |
| 28 | 34 | } from "lucide-react"; |
| @@ -31,6 +37,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro · 2 unmodified lines | ||
| 31 | 37 | import { |
| 32 | 38 | filterPullRequestsByInvolvement, |
| 33 | 39 | findScopedProject, |
| 40 | + collectPullRequestListFacets, | |
| 34 | 41 | groupPullRequestsByInvolvement, |
| 35 | 42 | matchesPullRequestFilters, |
| 36 | 43 | matchesPullRequestQuery, |
| @@ -38,6 +45,7 @@ import { · 1 unmodified line | ||
| 38 | 45 | narrowPullRequestsToFilters, |
| 39 | 46 | mergePullRequestDiffStats, |
| 40 | 47 | partitionPullRequestsWithPriority, |
| 48 | + pullRequestDiffStatKey, | |
| 41 | 49 | pullRequestEntryKey, |
| 42 | 50 | pullRequestEntryViewer, |
| 43 | 51 | rankPullRequestMatches, |
| @@ -58,6 +66,7 @@ import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequ · 14 unmodified lines | ||
| 58 | 66 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; |
| 59 | 67 | import { |
| 60 | 68 | PullRequestFiltersMenu, |
| 69 | + PullRequestFilterOptionIcon, | |
| 61 | 70 | PullRequestSearchInput, |
| 62 | 71 | pullRequestHostLabel, |
| 63 | 72 | pullRequestProjectKey, |
| @@ -83,6 +92,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ · 19 unmodified lines | ||
| 83 | 92 | import { SidebarInset } from "../components/ui/sidebar"; |
| 84 | 93 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; |
| 85 | 94 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; |
| 95 | +import { toSortableTimestamp } from "../lib/threadSort"; | |
| 86 | 96 | import { |
| 87 | 97 | selectActiveRightPanelSurface, |
| 88 | 98 | selectSelectedRightPanelSurface, |
| @@ -136,8 +146,13 @@ export interface PullRequestsSearch { · 47 unmodified lines | ||
| 136 | 146 | readonly draft?: "only" | "hide"; |
| 137 | 147 | readonly review?: NonNullable<PullRequestListFilters["review"]>; |
| 138 | 148 | readonly checks?: NonNullable<PullRequestListFilters["checks"]>; |
| 149 | + readonly author?: string; | |
| 150 | + readonly labels?: ReadonlyArray<string>; | |
| 151 | + readonly sort?: PullRequestListSort; | |
| 139 | 152 | } |
| 140 | 153 | |
| 154 | +type PullRequestListSort = "updated" | "newest" | "oldest" | "largest" | "smallest"; | |
| 155 | + | |
| 141 | 156 | // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. |
| 142 | 157 | const INVOLVEMENT_TABS = [ |
| 143 | 158 | { value: "all", label: "All", Icon: LayersIcon }, |
| @@ -152,6 +167,14 @@ const STATE_TABS = [ · 8 unmodified lines | ||
| 152 | 167 | { value: "merged", label: "Merged", Icon: GitMergeIcon }, |
| 153 | 168 | ] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>; |
| 154 | 169 | |
| 170 | +const SORT_OPTIONS = [ | |
| 171 | + { value: "updated", label: "Recently updated", Icon: ClockIcon }, | |
| 172 | + { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, | |
| 173 | + { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, | |
| 174 | + { value: "largest", label: "Largest shown", Icon: Maximize2Icon }, | |
| 175 | + { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon }, | |
| 176 | +] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListSort>>; | |
| 177 | + | |
| 155 | 178 | /** Long enough that a keystroke does not become a request, short enough to feel answered. */ |
| 156 | 179 | const SEARCH_DEBOUNCE_MS = 250; |
| 157 | 180 | /** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */ |
| @@ -181,6 +204,26 @@ const EMPTY_PREVIEW_SESSIONS = {}; · 23 unmodified lines | ||
| 181 | 204 | const EMPTY_PREVIEW_DESKTOP_STATE = {}; |
| 182 | 205 | const EMPTY_TERMINAL_LABELS = new Map<string, string>(); |
| 183 | 206 | const EMPTY_PENDING_SURFACES = new Set<string>(); |
| 207 | +const MAX_SEARCH_LABEL_CANDIDATES = 100; | |
| 208 | + | |
| 209 | +function pullRequestSearchLabels(raw: unknown): Partial<Pick<PullRequestsSearch, "labels">> { | |
| 210 | + const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( | |
| 211 | + 0, | |
| 212 | + MAX_SEARCH_LABEL_CANDIDATES, | |
| 213 | + ); | |
| 214 | + const labels: Array<string> = []; | |
| 215 | + const seen = new Set<string>(); | |
| 216 | + for (const rawValue of values) { | |
| 217 | + if (typeof rawValue !== "string") continue; | |
| 218 | + const value = rawValue.trim().slice(0, 200); | |
| 219 | + const key = value.toLowerCase(); | |
| 220 | + if (value.length === 0 || seen.has(key)) continue; | |
| 221 | + labels.push(value); | |
| 222 | + seen.add(key); | |
| 223 | + if (labels.length === 10) break; | |
| 224 | + } | |
| 225 | + return labels.length === 0 ? {} : { labels }; | |
| 226 | +} | |
| 184 | 227 | |
| 185 | 228 | export const Route = createFileRoute("/_chat/pull-requests")({ |
| 186 | 229 | validateSearch: (raw: Record<string, unknown>): PullRequestsSearch => ({ |
| @@ -188,6 +231,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ · 1 unmodified line | ||
| 188 | 231 | raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", |
| 189 | 232 | state: |
| 190 | 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 | 237 | ...(typeof raw.repository === "string" && raw.repository |
| 192 | 238 | ? { repository: raw.repository.slice(0, 200) } |
| 193 | 239 | : {}), |
| @@ -216,12 +262,17 @@ export const Route = createFileRoute("/_chat/pull-requests")({ · 22 unmodified lines | ||
| 216 | 262 | ? { review: raw.review } |
| 217 | 263 | : {}), |
| 218 | 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 | 270 | component: PullRequestsRouteView, |
| 221 | 271 | }); |
| 222 | 272 | |
| 223 | 273 | function PullRequestsRouteView() { |
| 224 | 274 | const search = Route.useSearch(); |
| 275 | + const sort = search.sort ?? "updated"; | |
| 225 | 276 | const navigate = useNavigate({ from: Route.fullPath }); |
| 226 | 277 | const { environments } = useEnvironments(); |
| 227 | 278 | // Every connected environment that has said it can list pull requests. Sorted, so the query |
| @@ -416,6 +467,7 @@ function PullRequestsRouteView() { · 188 unmodified lines | ||
| 416 | 467 | return { |
| 417 | 468 | involvement: next.involvement ?? previous.involvement, |
| 418 | 469 | state: next.state ?? previous.state, |
| 470 | + ...(next.sort && next.sort !== "updated" ? { sort: next.sort } : {}), | |
| 419 | 471 | ...(next.repository ? { repository: next.repository } : {}), |
| 420 | 472 | ...(next.number ? { number: next.number } : {}), |
| 421 | 473 | ...(next.projectId ? { projectId: next.projectId } : {}), |
| @@ -429,6 +481,8 @@ function PullRequestsRouteView() { · 7 unmodified lines | ||
| 429 | 481 | ...(next.draft ? { draft: next.draft } : {}), |
| 430 | 482 | ...(next.review ? { review: next.review } : {}), |
| 431 | 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 | 488 | replace: true, |
| @@ -458,6 +512,7 @@ function PullRequestsRouteView() { · 23 unmodified lines | ||
| 458 | 512 | // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is |
| 459 | 513 | // late but the page is not. |
| 460 | 514 | const typedQuery = (search.q ?? "").trim(); |
| 515 | + const [filtersOpen, setFiltersOpen] = useState(false); | |
| 461 | 516 | const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS); |
| 462 | 517 | const querySettled = typedQuery === sentQuery; |
| 463 | 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() { · 8 unmodified lines | ||
| 472 | 527 | ...(search.draft ? { draft: search.draft } : {}), |
| 473 | 528 | ...(search.review ? { review: search.review } : {}), |
| 474 | 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 | 535 | const menuFiltered = Object.keys(menuFilters).length > 0; |
| 479 | 536 | // 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 | 538 | const filters = useMemo( |
| 482 | 539 | (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }), |
| 483 | 540 | [menuFilters, sentParsed.filters], |
| @@ -548,7 +605,7 @@ function PullRequestsRouteView() { · 64 unmodified lines | ||
| 548 | 605 | [environmentQueries], |
| 549 | 606 | ); |
| 550 | 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 | 609 | const filterKey = `${scopeKey}:${sentQuery}`; |
| 553 | 610 | // Where the next slice carries on from, per repository within each environment, as that |
| 554 | 611 | // environment handed it back. Sending it is what makes a second page cost a second page rather |
| @@ -660,6 +717,21 @@ function PullRequestsRouteView() { · 105 unmodified lines | ||
| 660 | 717 | ], |
| 661 | 718 | ); |
| 662 | 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 | 735 | // The priority groups' own reads. The feed below is paginated by recency, so an older authored |
| 664 | 736 | // or review-requested row can be missing from its first page; partitioned from these |
| 665 | 737 | // server-filtered reads instead, the priority view is complete up front and a continuation can |
| @@ -720,6 +792,7 @@ function PullRequestsRouteView() { · 54 unmodified lines | ||
| 720 | 792 | } |
| 721 | 793 | refreshList(); |
| 722 | 794 | baselineQuery.refresh(); |
| 795 | + facetQuery.refresh(); | |
| 723 | 796 | authoredQuery.refresh(); |
| 724 | 797 | reviewingQuery.refresh(); |
| 725 | 798 | statsQuery.refresh(); |
| @@ -982,6 +1055,17 @@ function PullRequestsRouteView() { · 256 unmodified lines | ||
| 982 | 1055 | |
| 983 | 1056 | const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS; |
| 984 | 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 | + ); | |
| 985 | 1069 | |
| 986 | 1070 | /** The hosts that narrowed the listing themselves, so their answer is not narrowed again. */ |
| 987 | 1071 | const searchingHosts = useMemo( |
| @@ -1170,6 +1254,40 @@ function PullRequestsRouteView() { · 182 unmodified lines | ||
| 1170 | 1254 | if (stats === null) return; |
| 1171 | 1255 | setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); |
| 1172 | 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]); | |
| 1173 | 1291 | |
| 1174 | 1292 | const linkedSelection = useMemo( |
| 1175 | 1293 | () => |
| @@ -1347,6 +1465,7 @@ function PullRequestsRouteView() { · 171 unmodified lines | ||
| 1347 | 1465 | onRefresh={() => void refreshFromHost()} |
| 1348 | 1466 | query={typedQuery} |
| 1349 | 1467 | filtered={ |
| 1468 | + menuFiltered || | |
| 1350 | 1469 | search.state !== "open" || |
| 1351 | 1470 | search.involvement !== "all" || |
| 1352 | 1471 | scopedProjectId !== undefined || |
| @@ -1360,7 +1479,7 @@ function PullRequestsRouteView() { · 7 unmodified lines | ||
| 1360 | 1479 | /> |
| 1361 | 1480 | ) : ( |
| 1362 | 1481 | <div className="space-y-3"> |
| 1363 | − {groups.map((group) => ( | |
| 1482 | + {displayGroups.map((group) => ( | |
| 1364 | 1483 | <div key={group.key} className="space-y-0.5"> |
| 1365 | 1484 | {group.label ? ( |
| 1366 | 1485 | <h2 className="px-3 pb-0.5 text-xs font-medium text-muted-foreground/70"> |
| @@ -1370,10 +1489,7 @@ function PullRequestsRouteView() { · 3 unmodified lines | ||
| 1370 | 1489 | {group.entries.map((entry) => ( |
| 1371 | 1490 | <PullRequestRow |
| 1372 | 1491 | 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} | |
| 1377 | 1493 | showProjectTitle |
| 1378 | 1494 | showProvider={showProvider} |
| 1379 | 1495 | {...(capableEnvironments.length > 1 && |
| @@ -1449,8 +1565,20 @@ function PullRequestsRouteView() { · 69 unmodified lines | ||
| 1449 | 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 | 1579 | const filtersMenu = ( |
| 1453 | 1580 | <PullRequestFiltersMenu |
| 1581 | + onOpenChange={setFiltersOpen} | |
| 1454 | 1582 | state={search.state} |
| 1455 | 1583 | stateOptions={STATE_TABS} |
| 1456 | 1584 | onState={(state) => updateListScope({ state })} |
| @@ -1459,8 +1587,16 @@ function PullRequestsRouteView() { · 2 unmodified lines | ||
| 1459 | 1587 | onInvolvement={(involvement) => updateListScope({ involvement })} |
| 1460 | 1588 | filters={menuFilters} |
| 1461 | 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 | 1600 | host={search.host} |
| 1465 | 1601 | hostOptions={hostMenuOptions} |
| 1466 | 1602 | onHost={(host) => updateListScope({ host })} |
| @@ -1493,6 +1629,7 @@ function PullRequestsRouteView() { · 26 unmodified lines | ||
| 1493 | 1629 | onState: (state: PullRequestListState) => updateListScope({ state }), |
| 1494 | 1630 | onHost: (host: string | undefined) => updateListScope({ host }), |
| 1495 | 1631 | searchInput, |
| 1632 | + sortMenu, | |
| 1496 | 1633 | filtersMenu, |
| 1497 | 1634 | rightPanelControl: |
| 1498 | 1635 | // Footprint reserve while the panel is closed: the toggle itself stays |
| @@ -1625,12 +1762,18 @@ function PullRequestsRouteView() { · 126 unmodified lines | ||
| 1625 | 1762 | /** A compact stand-in for one pill group when the header is narrow. */ |
| 1626 | 1763 | function CompactFilterMenu<Value extends string>({ |
| 1627 | 1764 | label, |
| 1765 | + triggerIcon, | |
| 1766 | + triggerLabel, | |
| 1767 | + outlined = false, | |
| 1628 | 1768 | value, |
| 1629 | 1769 | options, |
| 1630 | 1770 | onChange, |
| 1631 | 1771 | className, |
| 1632 | 1772 | }: { |
| 1633 | 1773 | label: string; |
| 1774 | + triggerIcon?: ReactNode; | |
| 1775 | + triggerLabel?: string; | |
| 1776 | + outlined?: boolean; | |
| 1634 | 1777 | value: Value; |
| 1635 | 1778 | options: ReadonlyArray<PullRequestFilterOption<Value>>; |
| 1636 | 1779 | onChange: (value: Value) => void; |
| @@ -1641,14 +1784,28 @@ function CompactFilterMenu<Value extends string>({ · 4 unmodified lines | ||
| 1641 | 1784 | return ( |
| 1642 | 1785 | <Menu> |
| 1643 | 1786 | <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 | + } | |
| 1649 | 1797 | > |
| 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 | + )} | |
| 1652 | 1809 | </MenuTrigger> |
| 1653 | 1810 | <MenuPopup align="start" side="bottom" className="min-w-40"> |
| 1654 | 1811 | <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}> |
| @@ -1661,7 +1818,7 @@ function CompactFilterMenu<Value extends string>({ · 6 unmodified lines | ||
| 1661 | 1818 | className="data-disabled:pointer-events-auto" |
| 1662 | 1819 | > |
| 1663 | 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 | 1822 | {option.label} |
| 1666 | 1823 | </span> |
| 1667 | 1824 | </MenuRadioItem> |
| @@ -1769,6 +1926,7 @@ function PullRequestsColumn({ · 101 unmodified lines | ||
| 1769 | 1926 | onState, |
| 1770 | 1927 | onHost, |
| 1771 | 1928 | searchInput, |
| 1929 | + sortMenu, | |
| 1772 | 1930 | filtersMenu, |
| 1773 | 1931 | rightPanelControl, |
| 1774 | 1932 | titlebarControls, |
| @@ -1786,6 +1944,7 @@ function PullRequestsColumn({ · 11 unmodified lines | ||
| 1786 | 1944 | onState: (state: PullRequestListState) => void; |
| 1787 | 1945 | onHost: (host: string | undefined) => void; |
| 1788 | 1946 | searchInput: ReactNode; |
| 1947 | + sortMenu: ReactNode; | |
| 1789 | 1948 | filtersMenu: ReactNode; |
| 1790 | 1949 | rightPanelControl: ReactNode; |
| 1791 | 1950 | titlebarControls: ReactNode; |
| @@ -1934,6 +2093,7 @@ function PullRequestsColumn({ · 142 unmodified lines | ||
| 1934 | 2093 | <div className="flex flex-col gap-3"> |
| 1935 | 2094 | <div ref={inFlowSearchRef} className="flex items-center gap-2"> |
| 1936 | 2095 | {searchInput} |
| 2096 | + {sortMenu} | |
| 1937 | 2097 | {filtersMenu} |
| 1938 | 2098 | {!condensed ? ( |
| 1939 | 2099 | <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} /> |
| @@ -41,6 +41,9 @@ T3 Code works with the platforms your team already uses: · 40 unmodified lines | ||
| 41 | 41 | |
| 42 | 42 | - See if your current branch already has an open PR/MR |
| 43 | 43 | - 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 | 47 | - While working in a thread, open linked reviews in the same compact right-panel tabs without |
| 45 | 48 | leaving the conversation |
| 46 | 49 | - Open the review directly in your browser with one click |