| author | |
| committer | |
| log | d35c71d1b975660ec6a71bbceb6aa7dfd0e8c3d5 |
| tree | d798297d076ca5a498e073d8934bd85fae92f734 |
| parent | b17cc3d1bf0f4a5deee6dd6a470e36970b864a9b |
| signature | Signed by PGP key B5690EEEBB952194 |
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(); |
| 131 | 132 | ||
| 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(); |
| 161 | 162 | ||
| 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"; |
| 22 | import type { ElementType } from "react"; | 24 | import { type ElementType, useState } from "react"; |
| 23 | 25 | ||
| 24 | import { cn } from "~/lib/utils"; | ||
| 25 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; | 26 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 26 | import { ProjectFavicon } from "../ProjectFavicon"; | 27 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 27 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; | 28 | import { 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"; |
| 29 | 30 | ||
| 30 | import { | 31 | import { |
| 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"; |
| 39 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 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 | export interface PullRequestFilterOption<Value extends string> { | 53 | export 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 | } |
| 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 | export interface PullRequestExpectedHost { | 83 | export 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 | } |
| 99 | 129 | ||
| 100 | /** | 130 | /** |
| 101 | * Every list filter lives behind the one filter icon so the control row stays two controls | 131 | * 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 its | 132 | * 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 | */ |
| 106 | const ALL_PROJECTS_VALUE = "all"; | 134 | const 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 | } |
| 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 | export function PullRequestFiltersMenu({ | 397 | export 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 real | 435 | * 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 in | 477 | 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 | <MenuTrigger | 516 | <MenuTrigger |
| 277 | render={ | 517 | render={ |
| 278 | <Button | 518 | <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 | <span | 526 | {filterCount > 0 ? ( |
| 289 | aria-hidden | 527 | <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 | <PullRequestFilterRadioGroup | 533 | <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 | <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 | 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 | <PullRequestFilterRadioGroup | 581 | <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 | <PullRequestFilterRadioGroup | 592 | <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 | <MenuRadioGroup | 603 | <PullRequestFilterRadioSubmenu |
| 355 | value={ | 604 | label="Project" |
| 356 | projectId === undefined || projectEnvironmentId === undefined | 605 | value={projectValue} |
| 357 | ? ALL_PROJECTS_VALUE | 606 | 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"; |
| 7 | 7 | ||
| 8 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 8 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 9 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; | 9 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 10 | import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | 10 | import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; |
| 11 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; | 11 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; |
| 12 | import { | 12 | import { |
| 13 | PullRequestActorLabel, | 13 | PullRequestActorLabel, |
| ... | @@ -16,6 +16,23 @@ import { | ... | @@ -16,6 +16,23 @@ import { |
| 16 | PullRequestStateGlyph, | 16 | PullRequestStateGlyph, |
| 17 | } from "./pullRequestPresentation"; | 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 | function PullRequestRowImpl({ | 36 | function 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 of | 139 | {/* 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"; |
| 57 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; | 57 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; |
| 58 | import { PullRequestReactionBar } from "./PullRequestReactions"; | 58 | import { PullRequestReactionBar } from "./PullRequestReactions"; |
| 59 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; | 59 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; |
| 60 | import { pullRequestLabelColor } from "./pullRequestList.logic"; | ||
| 60 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; | 61 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; |
| 61 | 62 | ||
| 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 | } |
| 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 | /** 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. */ |
| 74 | function CommentAuthor({ actor }: { actor: PullRequestActor | null }) { | 69 | function 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 | <span | 619 | <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"; |
| 10 | import type { | 10 | import 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 | } |
| 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 | * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than | 56 | * 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 a | 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 { | ... | @@ -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 | } |
| 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 | * The signed-in login for the host a row came from, or null where none was given. Shared by | 134 | * 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 | } |
| 440 | 508 | ||
| 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. */ |
| 442 | const diffStatKey = (row: { | 510 | export 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"; |
| 14 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 14 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 15 | import { | 15 | import { |
| 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 |
| 31 | import { | 37 | import { |
| 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 |
| 58 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; | 66 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; |
| 59 | import { | 67 | import { |
| 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 "../ |
| 83 | import { SidebarInset } from "../components/ui/sidebar"; | 92 | import { SidebarInset } from "../components/ui/sidebar"; |
| 84 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; | 93 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; |
| 85 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; | 94 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; |
| 95 | import { toSortableTimestamp } from "../lib/threadSort"; | ||
| 86 | import { | 96 | import { |
| 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 | } |
| 140 | 153 | ||
| 154 | type 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. |
| 142 | const INVOLVEMENT_TABS = [ | 157 | const 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>>; |
| 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 | /** 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. */ |
| 156 | const SEARCH_DEBOUNCE_MS = 250; | 179 | const 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 = {}; |
| 181 | const EMPTY_PREVIEW_DESKTOP_STATE = {}; | 204 | const EMPTY_PREVIEW_DESKTOP_STATE = {}; |
| 182 | const EMPTY_TERMINAL_LABELS = new Map<string, string>(); | 205 | const EMPTY_TERMINAL_LABELS = new Map<string, string>(); |
| 183 | const EMPTY_PENDING_SURFACES = new Set<string>(); | 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 | export const Route = createFileRoute("/_chat/pull-requests")({ | 228 | export 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.repository | 237 | ...(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 | }); |
| 222 | 272 | ||
| 223 | function PullRequestsRouteView() { | 273 | function 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 query | 278 | // 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 is | 512 | // 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 more | 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 | 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 that | 610 | // 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 rather | 611 | // 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 authored | 735 | // 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 these | 736 | // 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 can | 737 | // 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() { |
| 982 | 1055 | ||
| 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 | ); | ||
| 985 | 1069 | ||
| 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]); | ||
| 1173 | 1291 | ||
| 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 | <PullRequestRow | 1490 | <PullRequestRow |
| 1372 | key={pullRequestEntryKey(entry)} | 1491 | key={pullRequestEntryKey(entry)} |
| 1373 | // A row whose host reported its line counts keeps them; one whose host left | 1492 | 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 | showProjectTitle | 1493 | 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 | <PullRequestFiltersMenu | 1580 | <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 stays | 1635 | // 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. */ |
| 1626 | function CompactFilterMenu<Value extends string>({ | 1763 | function 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 | <MenuTrigger | 1786 | <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: |
| 41 | 41 | ||
| 42 | - See if your current branch already has an open PR/MR | 42 | - 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 panel | 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 | - While working in a thread, open linked reviews in the same compact right-panel tabs without | 47 | - While working in a thread, open linked reviews in the same compact right-panel tabs without |
| 45 | leaving the conversation | 48 | leaving the conversation |
| 46 | - Open the review directly in your browser with one click | 49 | - Open the review directly in your browser with one click |