| author | |
| committer | |
| log | 3c73fa7ce02b7ee6b2904ec58e47c06262aebf5d |
| tree | 8df36971c48515b0e9f7780dbf99662a5f3c681d |
| parent | 62d39bf00d5ddd83a9b36a81321fe9aa4d2502bb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Theo Browne <me@t3.gg>5 files changed, 561 insertions(+), 61 deletions(-)
apps/web/src/components/pullRequest/PullRequestRow.tsx+8-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { SearchIcon } from "lucide-react"; |
| 2 | import { memo } from "react"; | |
| 2 | import { memo, type RefCallback } from "react"; | |
| 3 | 3 | |
| 4 | 4 | import { cn } from "~/lib/utils"; |
| 5 | 5 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| ... | ... | @@ -40,6 +40,8 @@ function PullRequestRowImpl({ |
| 40 | 40 | showProvider, |
| 41 | 41 | environmentLabel, |
| 42 | 42 | matchedElsewhere, |
| 43 | statsKey, | |
| 44 | statsRef, | |
| 43 | 45 | onSelect, |
| 44 | 46 | }: { |
| 45 | 47 | entry: EnvironmentPullRequestEntry; |
| ... | ... | @@ -54,11 +56,16 @@ function PullRequestRowImpl({ |
| 54 | 56 | * commit message. Saying so is the difference between a result and an apparently random row. |
| 55 | 57 | */ |
| 56 | 58 | matchedElsewhere?: boolean; |
| 59 | /** Used by the list's shared visibility observer to defer optional line-count reads. */ | |
| 60 | statsKey?: string; | |
| 61 | statsRef?: RefCallback<HTMLButtonElement>; | |
| 57 | 62 | onSelect: (entry: EnvironmentPullRequestEntry) => void; |
| 58 | 63 | }) { |
| 59 | 64 | const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); |
| 60 | 65 | return ( |
| 61 | 66 | <button |
| 67 | ref={statsRef} | |
| 68 | data-pull-request-stats-key={statsKey} | |
| 62 | 69 | type="button" |
| 63 | 70 | aria-current={selected ? "true" : undefined} |
| 64 | 71 | onClick={() => onSelect(entry)} |
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+196| ... | ... | @@ -11,6 +11,10 @@ import { |
| 11 | 11 | matchesPullRequestFilters, |
| 12 | 12 | matchesPullRequestQuery, |
| 13 | 13 | parsePullRequestQuery, |
| 14 | pullRequestStatsBatches, | |
| 15 | pullRequestStatsKeysToRequest, | |
| 16 | pullRequestStatsRefreshBatches, | |
| 17 | pullRequestStatsRequestBatches, | |
| 14 | 18 | mergePullRequestDiffStats, |
| 15 | 19 | narrowPullRequestsToFilters, |
| 16 | 20 | partitionPullRequestsWithPriority, |
| ... | ... | @@ -18,6 +22,7 @@ import { |
| 18 | 22 | writePullRequestListSnapshot, |
| 19 | 23 | rankPullRequestMatches, |
| 20 | 24 | scorePullRequestMatch, |
| 25 | retainVisiblePullRequestStatsBatches, | |
| 21 | 26 | withDiffStat, |
| 22 | 27 | resolveProjectScope, |
| 23 | 28 | resolveQueryEnvironmentIds, |
| ... | ... | @@ -56,6 +61,197 @@ function entry( |
| 56 | 61 | } as EnvironmentPullRequestEntry; |
| 57 | 62 | } |
| 58 | 63 | |
| 64 | describe("visible pull request line-count targets", () => { | |
| 65 | it("does not request a row again after its received batch is pruned", () => { | |
| 66 | const entries = [entry({ number: 1 }), entry({ number: 2 })]; | |
| 67 | const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); | |
| 68 | const firstKey = pullRequestEntryKey(entries[0]!); | |
| 69 | const secondKey = pullRequestEntryKey(entries[1]!); | |
| 70 | const completedStats = mergePullRequestDiffStats(new Map(), [ | |
| 71 | { | |
| 72 | environmentId: ENV_1, | |
| 73 | projectId: "project-1", | |
| 74 | number: 1, | |
| 75 | additions: 1, | |
| 76 | deletions: 1, | |
| 77 | }, | |
| 78 | ]); | |
| 79 | ||
| 80 | const keys = pullRequestStatsKeysToRequest( | |
| 81 | entriesByKey, | |
| 82 | new Set([firstKey, secondKey]), | |
| 83 | [], | |
| 84 | completedStats, | |
| 85 | ); | |
| 86 | expect([...keys]).toEqual([secondKey]); | |
| 87 | expect(pullRequestStatsBatches(entriesByKey, keys)[0]?.input.refs).toEqual([ | |
| 88 | { projectId: "project-1", repository: "pingdotgg/t3code", number: 2 }, | |
| 89 | ]); | |
| 90 | }); | |
| 91 | ||
| 92 | it("drops historical rows after a long scroll so refresh stays bounded to the viewport", () => { | |
| 93 | const entries = Array.from({ length: 500 }, (_, index) => entry({ number: index + 1 })); | |
| 94 | const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); | |
| 95 | const batches = entries.map( | |
| 96 | (item) => pullRequestStatsBatches(entriesByKey, new Set([pullRequestEntryKey(item)]))[0]!, | |
| 97 | ); | |
| 98 | const visibleKeys = new Set(entries.slice(-12).map(pullRequestEntryKey)); | |
| 99 | ||
| 100 | const retained = retainVisiblePullRequestStatsBatches(batches, visibleKeys); | |
| 101 | expect(retained).toHaveLength(12); | |
| 102 | expect(retained.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual( | |
| 103 | entries.slice(-12).map((item) => item.number), | |
| 104 | ); | |
| 105 | }); | |
| 106 | ||
| 107 | it("keeps every per-environment batch within the stats contract limit", () => { | |
| 108 | const entries = Array.from({ length: 501 }, (_, index) => entry({ number: index + 1 })); | |
| 109 | const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); | |
| 110 | const batches = pullRequestStatsBatches( | |
| 111 | entriesByKey, | |
| 112 | new Set(entries.map(pullRequestEntryKey)), | |
| 113 | ); | |
| 114 | ||
| 115 | expect(batches.map((batch) => batch.input.refs.length)).toEqual([500, 1]); | |
| 116 | expect(batches.flatMap((batch) => [...batch.keys])).toEqual(entries.map(pullRequestEntryKey)); | |
| 117 | }); | |
| 118 | ||
| 119 | it("selects visible rows for date modes and every uncached row for size modes", () => { | |
| 120 | const entries = [ | |
| 121 | entry({ number: 1 }), | |
| 122 | entry({ number: 2 }), | |
| 123 | entry({ number: 3, environmentId: "env-2" as EnvironmentId }), | |
| 124 | ]; | |
| 125 | const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); | |
| 126 | const [firstKey, secondKey] = entries.map(pullRequestEntryKey); | |
| 127 | const cachedStats = mergePullRequestDiffStats(new Map(), [ | |
| 128 | { | |
| 129 | environmentId: ENV_1, | |
| 130 | projectId: "project-1", | |
| 131 | number: 1, | |
| 132 | additions: 1, | |
| 133 | deletions: 1, | |
| 134 | }, | |
| 135 | ]); | |
| 136 | ||
| 137 | const visible = pullRequestStatsRequestBatches({ | |
| 138 | entriesByKey, | |
| 139 | candidateKeys: new Set([firstKey!, secondKey!]), | |
| 140 | policy: "visible", | |
| 141 | activeBatches: [], | |
| 142 | statsByRow: cachedStats, | |
| 143 | }); | |
| 144 | expect(visible.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2]); | |
| 145 | ||
| 146 | const eager = pullRequestStatsRequestBatches({ | |
| 147 | entriesByKey, | |
| 148 | candidateKeys: new Set([firstKey!]), | |
| 149 | policy: "eager", | |
| 150 | activeBatches: [], | |
| 151 | statsByRow: cachedStats, | |
| 152 | }); | |
| 153 | expect(eager.map((batch) => batch.environmentId)).toEqual([ENV_1, "env-2"]); | |
| 154 | expect(eager.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2, 3]); | |
| 155 | }); | |
| 156 | ||
| 157 | it("refreshes only visible rows unless size sorting needs every loaded row", () => { | |
| 158 | const entries = [entry({ number: 1 }), entry({ number: 2 }), entry({ number: 3 })]; | |
| 159 | const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); | |
| 160 | const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]); | |
| 161 | const cachedStats = mergePullRequestDiffStats( | |
| 162 | new Map(), | |
| 163 | entries.map((item) => ({ | |
| 164 | environmentId: ENV_1, | |
| 165 | projectId: item.projectId, | |
| 166 | number: item.number, | |
| 167 | additions: item.additions, | |
| 168 | deletions: item.deletions, | |
| 169 | })), | |
| 170 | ); | |
| 171 | ||
| 172 | const visible = pullRequestStatsRequestBatches({ | |
| 173 | entriesByKey, | |
| 174 | candidateKeys: visibleKeys, | |
| 175 | policy: "visible", | |
| 176 | activeBatches: [], | |
| 177 | statsByRow: cachedStats, | |
| 178 | refresh: true, | |
| 179 | }); | |
| 180 | expect(visible[0]?.input.refs.map((ref) => ref.number)).toEqual([2]); | |
| 181 | ||
| 182 | const eager = pullRequestStatsRequestBatches({ | |
| 183 | entriesByKey, | |
| 184 | candidateKeys: visibleKeys, | |
| 185 | policy: "eager", | |
| 186 | activeBatches: [], | |
| 187 | statsByRow: cachedStats, | |
| 188 | refresh: true, | |
| 189 | }); | |
| 190 | expect(eager[0]?.input.refs.map((ref) => ref.number)).toEqual([1, 2, 3]); | |
| 191 | }); | |
| 192 | ||
| 193 | it("ignores a late refresh after the filter or stats policy changes", () => { | |
| 194 | const entries = [entry({ number: 1 }), entry({ number: 2 })]; | |
| 195 | const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); | |
| 196 | const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]); | |
| 197 | const requestedScope = { key: "open", policy: "visible" } as const; | |
| 198 | const refresh = (currentScope: { key: string; policy: "visible" | "eager" }) => | |
| 199 | pullRequestStatsRefreshBatches({ | |
| 200 | requestedScope, | |
| 201 | currentScope, | |
| 202 | entriesByKey, | |
| 203 | candidateKeys: visibleKeys, | |
| 204 | statsByRow: new Map(), | |
| 205 | }); | |
| 206 | ||
| 207 | expect(refresh(requestedScope)?.[0]?.input.refs.map((ref) => ref.number)).toEqual([2]); | |
| 208 | expect(refresh({ key: "closed", policy: "visible" })).toBeNull(); | |
| 209 | expect(refresh({ key: "open", policy: "eager" })).toBeNull(); | |
| 210 | }); | |
| 211 | ||
| 212 | it("does not add request batches again while rows are active or cached", () => { | |
| 213 | const entries = [entry({ number: 1 }), entry({ number: 2 })]; | |
| 214 | const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); | |
| 215 | const first = pullRequestStatsRequestBatches({ | |
| 216 | entriesByKey, | |
| 217 | candidateKeys: new Set(), | |
| 218 | policy: "eager", | |
| 219 | activeBatches: [], | |
| 220 | statsByRow: new Map(), | |
| 221 | }); | |
| 222 | ||
| 223 | expect( | |
| 224 | pullRequestStatsRequestBatches({ | |
| 225 | entriesByKey, | |
| 226 | candidateKeys: new Set(), | |
| 227 | policy: "eager", | |
| 228 | activeBatches: first, | |
| 229 | statsByRow: new Map(), | |
| 230 | }), | |
| 231 | ).toEqual([]); | |
| 232 | ||
| 233 | const cachedStats = mergePullRequestDiffStats( | |
| 234 | new Map(), | |
| 235 | entries.map((item) => ({ | |
| 236 | environmentId: ENV_1, | |
| 237 | projectId: item.projectId, | |
| 238 | number: item.number, | |
| 239 | additions: item.additions, | |
| 240 | deletions: item.deletions, | |
| 241 | })), | |
| 242 | ); | |
| 243 | expect( | |
| 244 | pullRequestStatsRequestBatches({ | |
| 245 | entriesByKey, | |
| 246 | candidateKeys: new Set(entries.map(pullRequestEntryKey)), | |
| 247 | policy: "visible", | |
| 248 | activeBatches: [], | |
| 249 | statsByRow: cachedStats, | |
| 250 | }), | |
| 251 | ).toEqual([]); | |
| 252 | }); | |
| 253 | }); | |
| 254 | ||
| 59 | 255 | describe("pull request involvement filtering", () => { |
| 60 | 256 | const entries = [ |
| 61 | 257 | entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }), |
apps/web/src/components/pullRequest/pullRequestList.logic.ts+150| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | resolvePullRequestAuthorFilter, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import type { |
| 11 | ProjectId, | |
| 11 | 12 | PullRequestActor, |
| 12 | 13 | PullRequestDiffStat, |
| 13 | 14 | PullRequestInvolvement, |
| ... | ... | @@ -429,6 +430,155 @@ export function pullRequestEntryKey(entry: ScopedEntry): string { |
| 429 | 430 | return `${scope}${entry.host}:${entry.repository}#${entry.number}`; |
| 430 | 431 | } |
| 431 | 432 | |
| 433 | export interface PullRequestStatsTarget { | |
| 434 | readonly environmentId: EnvironmentId; | |
| 435 | readonly input: { | |
| 436 | readonly refs: ReadonlyArray<{ | |
| 437 | readonly projectId: ProjectId; | |
| 438 | readonly repository: string; | |
| 439 | readonly number: number; | |
| 440 | }>; | |
| 441 | }; | |
| 442 | } | |
| 443 | ||
| 444 | export interface PullRequestStatsBatch extends PullRequestStatsTarget { | |
| 445 | readonly keys: ReadonlySet<string>; | |
| 446 | } | |
| 447 | ||
| 448 | export type PullRequestStatsPolicy = "visible" | "eager"; | |
| 449 | ||
| 450 | export interface PullRequestStatsScope { | |
| 451 | readonly key: string; | |
| 452 | readonly policy: PullRequestStatsPolicy; | |
| 453 | } | |
| 454 | ||
| 455 | const MAX_PULL_REQUEST_STATS_REFS = 500; | |
| 456 | ||
| 457 | /** Excludes rows already covered by an active batch or the received-count cache. */ | |
| 458 | export function pullRequestStatsKeysToRequest( | |
| 459 | entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>, | |
| 460 | enteredKeys: ReadonlySet<string>, | |
| 461 | batches: ReadonlyArray<PullRequestStatsBatch>, | |
| 462 | statsByRow: ReadonlyMap<string, unknown>, | |
| 463 | ): ReadonlySet<string> { | |
| 464 | const requested = new Set(batches.flatMap((batch) => [...batch.keys])); | |
| 465 | return new Set( | |
| 466 | [...enteredKeys].filter((key) => { | |
| 467 | const entry = entriesByKey.get(key); | |
| 468 | return ( | |
| 469 | entry !== undefined && !requested.has(key) && !statsByRow.has(pullRequestDiffStatKey(entry)) | |
| 470 | ); | |
| 471 | }), | |
| 472 | ); | |
| 473 | } | |
| 474 | ||
| 475 | /** Groups selected rows into bounded, immutable line-count reads per environment. */ | |
| 476 | export function pullRequestStatsBatches( | |
| 477 | entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>, | |
| 478 | keys: ReadonlySet<string>, | |
| 479 | ): ReadonlyArray<PullRequestStatsBatch> { | |
| 480 | const byEnvironment = new Map< | |
| 481 | EnvironmentId, | |
| 482 | Array<{ | |
| 483 | readonly key: string; | |
| 484 | readonly ref: PullRequestStatsTarget["input"]["refs"][number]; | |
| 485 | }> | |
| 486 | >(); | |
| 487 | for (const key of keys) { | |
| 488 | const entry = entriesByKey.get(key); | |
| 489 | if (entry === undefined) continue; | |
| 490 | const rows = byEnvironment.get(entry.environmentId) ?? []; | |
| 491 | rows.push({ | |
| 492 | key, | |
| 493 | ref: { | |
| 494 | projectId: entry.projectId, | |
| 495 | repository: entry.repository, | |
| 496 | number: entry.number, | |
| 497 | }, | |
| 498 | }); | |
| 499 | byEnvironment.set(entry.environmentId, rows); | |
| 500 | } | |
| 501 | return [...byEnvironment].flatMap(([environmentId, rows]) => { | |
| 502 | const batches: PullRequestStatsBatch[] = []; | |
| 503 | for (let index = 0; index < rows.length; index += MAX_PULL_REQUEST_STATS_REFS) { | |
| 504 | const batch = rows.slice(index, index + MAX_PULL_REQUEST_STATS_REFS); | |
| 505 | batches.push({ | |
| 506 | environmentId, | |
| 507 | input: { refs: batch.map((row) => row.ref) }, | |
| 508 | keys: new Set(batch.map((row) => row.key)), | |
| 509 | }); | |
| 510 | } | |
| 511 | return batches; | |
| 512 | }); | |
| 513 | } | |
| 514 | ||
| 515 | /** | |
| 516 | * Selects the next immutable stats batches. Size sorting needs every loaded row, while the other | |
| 517 | * modes only need rows near the viewport. Normal reads skip active and cached rows; an explicit | |
| 518 | * refresh asks for the selected rows again. | |
| 519 | */ | |
| 520 | export function pullRequestStatsRequestBatches({ | |
| 521 | entriesByKey, | |
| 522 | candidateKeys, | |
| 523 | policy, | |
| 524 | activeBatches, | |
| 525 | statsByRow, | |
| 526 | refresh = false, | |
| 527 | }: { | |
| 528 | readonly entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>; | |
| 529 | readonly candidateKeys: ReadonlySet<string>; | |
| 530 | readonly policy: PullRequestStatsPolicy; | |
| 531 | readonly activeBatches: ReadonlyArray<PullRequestStatsBatch>; | |
| 532 | readonly statsByRow: ReadonlyMap<string, unknown>; | |
| 533 | readonly refresh?: boolean; | |
| 534 | }): ReadonlyArray<PullRequestStatsBatch> { | |
| 535 | const requestedKeys = policy === "eager" ? new Set(entriesByKey.keys()) : candidateKeys; | |
| 536 | const keys = refresh | |
| 537 | ? requestedKeys | |
| 538 | : pullRequestStatsKeysToRequest(entriesByKey, requestedKeys, activeBatches, statsByRow); | |
| 539 | return pullRequestStatsBatches(entriesByKey, keys); | |
| 540 | } | |
| 541 | ||
| 542 | /** Ignores a refresh that finished after the list moved to another filter or stats policy. */ | |
| 543 | export function pullRequestStatsRefreshBatches({ | |
| 544 | requestedScope, | |
| 545 | currentScope, | |
| 546 | entriesByKey, | |
| 547 | candidateKeys, | |
| 548 | statsByRow, | |
| 549 | }: { | |
| 550 | readonly requestedScope: PullRequestStatsScope; | |
| 551 | readonly currentScope: PullRequestStatsScope; | |
| 552 | readonly entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>; | |
| 553 | readonly candidateKeys: ReadonlySet<string>; | |
| 554 | readonly statsByRow: ReadonlyMap<string, unknown>; | |
| 555 | }): ReadonlyArray<PullRequestStatsBatch> | null { | |
| 556 | if (requestedScope.key !== currentScope.key || requestedScope.policy !== currentScope.policy) { | |
| 557 | return null; | |
| 558 | } | |
| 559 | return pullRequestStatsRequestBatches({ | |
| 560 | entriesByKey, | |
| 561 | candidateKeys, | |
| 562 | policy: requestedScope.policy, | |
| 563 | activeBatches: [], | |
| 564 | statsByRow, | |
| 565 | refresh: true, | |
| 566 | }); | |
| 567 | } | |
| 568 | ||
| 569 | /** Drops completed batches once every row in them has left the observer window. */ | |
| 570 | export function retainVisiblePullRequestStatsBatches( | |
| 571 | batches: ReadonlyArray<PullRequestStatsBatch>, | |
| 572 | visibleKeys: ReadonlySet<string>, | |
| 573 | ): ReadonlyArray<PullRequestStatsBatch> { | |
| 574 | return batches.filter((batch) => { | |
| 575 | for (const key of batch.keys) { | |
| 576 | if (visibleKeys.has(key)) return true; | |
| 577 | } | |
| 578 | return false; | |
| 579 | }); | |
| 580 | } | |
| 581 | ||
| 432 | 582 | /** |
| 433 | 583 | * The priority groups built from the hosts' own answers rather than re-partitioned from the |
| 434 | 584 | * paginated feed. The feed is sliced by recency, so an older authored or review-requested row |
apps/web/src/routes/_chat.pull-requests.tsx+191-52| ... | ... | @@ -32,7 +32,15 @@ import { |
| 32 | 32 | RefreshCwIcon, |
| 33 | 33 | SearchIcon, |
| 34 | 34 | } from "lucide-react"; |
| 35 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 35 | import { | |
| 36 | useCallback, | |
| 37 | useEffect, | |
| 38 | useMemo, | |
| 39 | useRef, | |
| 40 | useState, | |
| 41 | type ReactNode, | |
| 42 | type RefObject, | |
| 43 | } from "react"; | |
| 36 | 44 | |
| 37 | 45 | import { |
| 38 | 46 | filterPullRequestsByInvolvement, |
| ... | ... | @@ -57,9 +65,15 @@ import { |
| 57 | 65 | withDiffStat, |
| 58 | 66 | writePullRequestListSnapshot, |
| 59 | 67 | scorePullRequestMatch, |
| 68 | pullRequestStatsRefreshBatches, | |
| 69 | pullRequestStatsRequestBatches, | |
| 70 | retainVisiblePullRequestStatsBatches, | |
| 60 | 71 | type EnvironmentPullRequestEntry, |
| 61 | 72 | type MergedPullRequestList, |
| 62 | 73 | type PullRequestDiffStats, |
| 74 | type PullRequestStatsBatch, | |
| 75 | type PullRequestStatsPolicy, | |
| 76 | type PullRequestStatsScope, | |
| 63 | 77 | type PullRequestPartitionsSnapshot, |
| 64 | 78 | } from "../components/pullRequest/pullRequestList.logic"; |
| 65 | 79 | import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; |
| ... | ... | @@ -273,6 +287,8 @@ export const Route = createFileRoute("/_chat/pull-requests")({ |
| 273 | 287 | function PullRequestsRouteView() { |
| 274 | 288 | const search = Route.useSearch(); |
| 275 | 289 | const sort = search.sort ?? "updated"; |
| 290 | const statsPolicy: PullRequestStatsPolicy = | |
| 291 | sort === "largest" || sort === "smallest" ? "eager" : "visible"; | |
| 276 | 292 | const navigate = useNavigate({ from: Route.fullPath }); |
| 277 | 293 | const { environments } = useEnvironments(); |
| 278 | 294 | // Every connected environment that has said it can list pull requests. Sorted, so the query |
| ... | ... | @@ -607,6 +623,8 @@ function PullRequestsRouteView() { |
| 607 | 623 | // Page size is view state, not a URL concern: a shared link should open the first page. |
| 608 | 624 | const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`; |
| 609 | 625 | const filterKey = `${scopeKey}:${sentQuery}`; |
| 626 | const statsScopeRef = useRef<PullRequestStatsScope>({ key: filterKey, policy: statsPolicy }); | |
| 627 | statsScopeRef.current = { key: filterKey, policy: statsPolicy }; | |
| 610 | 628 | // Where the next slice carries on from, per repository within each environment, as that |
| 611 | 629 | // environment handed it back. Sending it is what makes a second page cost a second page rather |
| 612 | 630 | // than the whole list again — and a repository it does not name has run out and is not read a |
| ... | ... | @@ -780,6 +798,7 @@ function PullRequestsRouteView() { |
| 780 | 798 | // of its own work is a button that gets pressed again, and buys the whole cascade twice. |
| 781 | 799 | const [invalidating, setInvalidating] = useState(false); |
| 782 | 800 | const refreshFromHost = async () => { |
| 801 | const requestedStatsScope = statsScopeRef.current; | |
| 783 | 802 | setInvalidating(true); |
| 784 | 803 | try { |
| 785 | 804 | // Every environment the page is reading, since what the reader pressed refresh for is the |
| ... | ... | @@ -795,7 +814,18 @@ function PullRequestsRouteView() { |
| 795 | 814 | facetQuery.refresh(); |
| 796 | 815 | authoredQuery.refresh(); |
| 797 | 816 | reviewingQuery.refresh(); |
| 798 | statsQuery.refresh(); | |
| 817 | const visible = visibleStatsKeys.current; | |
| 818 | const batches = pullRequestStatsRefreshBatches({ | |
| 819 | requestedScope: requestedStatsScope, | |
| 820 | currentScope: statsScopeRef.current, | |
| 821 | entriesByKey: entriesByStatsKey.current, | |
| 822 | candidateKeys: visible.key === requestedStatsScope.key ? visible.values : new Set(), | |
| 823 | statsByRow: statsByRowRef.current, | |
| 824 | }); | |
| 825 | if (batches !== null) { | |
| 826 | setStatsTargetState({ key: requestedStatsScope.key, batches }); | |
| 827 | statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input }))); | |
| 828 | } | |
| 799 | 829 | setDetailRefreshToken((token) => token + 1); |
| 800 | 830 | }; |
| 801 | 831 | const refreshing = invalidating || listQuery.isPending; |
| ... | ... | @@ -1122,6 +1152,7 @@ function PullRequestsRouteView() { |
| 1122 | 1152 | viewers, |
| 1123 | 1153 | ]); |
| 1124 | 1154 | |
| 1155 | const scrollRef = useRef<HTMLDivElement>(null); | |
| 1125 | 1156 | const sentinelRef = useRef<HTMLDivElement>(null); |
| 1126 | 1157 | useEffect(() => { |
| 1127 | 1158 | const sentinel = sentinelRef.current; |
| ... | ... | @@ -1156,7 +1187,7 @@ function PullRequestsRouteView() { |
| 1156 | 1187 | } |
| 1157 | 1188 | }, |
| 1158 | 1189 | // Start the next page slightly before the sentinel is on screen. |
| 1159 | { rootMargin: "240px" }, | |
| 1190 | { root: scrollRef.current, rootMargin: "240px" }, | |
| 1160 | 1191 | ); |
| 1161 | 1192 | observer.observe(sentinel); |
| 1162 | 1193 | return () => observer.disconnect(); |
| ... | ... | @@ -1220,35 +1251,136 @@ function PullRequestsRouteView() { |
| 1220 | 1251 | viewers, |
| 1221 | 1252 | ]); |
| 1222 | 1253 | |
| 1223 | // Keyed by every row being shown — the partitions can hold rows the feed has not paged to — | |
| 1224 | // so scrolling further asks only about what is new. One read per environment, each asking only | |
| 1225 | // about its own rows: a reference names a project, and a project belongs to one machine. | |
| 1226 | const statsTargets = useMemo(() => { | |
| 1227 | const refsByEnvironment = new Map< | |
| 1228 | EnvironmentId, | |
| 1229 | Array<{ projectId: ProjectId; repository: string; number: number }> | |
| 1230 | >(); | |
| 1231 | for (const group of groups) { | |
| 1232 | for (const entry of group.entries) { | |
| 1233 | const refs = refsByEnvironment.get(entry.environmentId) ?? []; | |
| 1234 | refs.push({ | |
| 1235 | projectId: entry.projectId, | |
| 1236 | repository: entry.repository, | |
| 1237 | number: entry.number, | |
| 1238 | }); | |
| 1239 | refsByEnvironment.set(entry.environmentId, refs); | |
| 1254 | // Date sorts keep optional line-count reads near the viewport. Size sorts need every loaded | |
| 1255 | // count before their order is final. Received counts stay cached across both policies. | |
| 1256 | const entriesByStatsKey = useRef<ReadonlyMap<string, EnvironmentPullRequestEntry>>(new Map()); | |
| 1257 | entriesByStatsKey.current = new Map( | |
| 1258 | groups.flatMap((group) => | |
| 1259 | group.entries.map((entry) => [pullRequestEntryKey(entry), entry] as const), | |
| 1260 | ), | |
| 1261 | ); | |
| 1262 | const visibleStatsKeys = useRef({ key: filterKey, values: new Set<string>() }); | |
| 1263 | const [statsByRow, setStatsByRow] = useState<PullRequestDiffStats>(() => new Map()); | |
| 1264 | const statsByRowRef = useRef(statsByRow); | |
| 1265 | statsByRowRef.current = statsByRow; | |
| 1266 | const [statsTargetState, setStatsTargetState] = useState<{ | |
| 1267 | readonly key: string; | |
| 1268 | readonly batches: ReadonlyArray<PullRequestStatsBatch>; | |
| 1269 | }>({ key: filterKey, batches: [] }); | |
| 1270 | const statsBatches = statsTargetState.key === filterKey ? statsTargetState.batches : []; | |
| 1271 | const statsTargets = useMemo( | |
| 1272 | () => | |
| 1273 | statsBatches.map(({ environmentId, input }) => ({ | |
| 1274 | environmentId, | |
| 1275 | input, | |
| 1276 | })), | |
| 1277 | [statsBatches], | |
| 1278 | ); | |
| 1279 | const statsObserver = useRef<IntersectionObserver | null>(null); | |
| 1280 | const statsRows = useRef(new Set<HTMLButtonElement>()); | |
| 1281 | const statsPending = useRef(true); | |
| 1282 | const statsPolicyRef = useRef(statsPolicy); | |
| 1283 | statsPolicyRef.current = statsPolicy; | |
| 1284 | const registerStatsRow = useCallback((node: HTMLButtonElement | null) => { | |
| 1285 | if (node === null || typeof IntersectionObserver === "undefined") return; | |
| 1286 | statsRows.current.add(node); | |
| 1287 | statsObserver.current?.observe(node); | |
| 1288 | return () => { | |
| 1289 | statsRows.current.delete(node); | |
| 1290 | statsObserver.current?.unobserve(node); | |
| 1291 | const key = node.dataset.pullRequestStatsKey; | |
| 1292 | const visible = visibleStatsKeys.current; | |
| 1293 | if ( | |
| 1294 | statsPolicyRef.current !== "visible" || | |
| 1295 | key === undefined || | |
| 1296 | !visible.values.delete(key) || | |
| 1297 | statsPending.current | |
| 1298 | ) { | |
| 1299 | return; | |
| 1240 | 1300 | } |
| 1241 | } | |
| 1242 | return [...refsByEnvironment].map(([environmentId, refs]) => ({ | |
| 1243 | environmentId, | |
| 1244 | input: { refs }, | |
| 1245 | })); | |
| 1246 | }, [groups]); | |
| 1301 | setStatsTargetState((current) => { | |
| 1302 | if (current.key !== visible.key) return current; | |
| 1303 | const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values); | |
| 1304 | return batches.length === current.batches.length ? current : { key: current.key, batches }; | |
| 1305 | }); | |
| 1306 | }; | |
| 1307 | }, []); | |
| 1308 | useEffect(() => { | |
| 1309 | if (statsPolicy !== "eager") return; | |
| 1310 | setStatsTargetState((current) => { | |
| 1311 | const batches = current.key === filterKey ? current.batches : []; | |
| 1312 | const added = pullRequestStatsRequestBatches({ | |
| 1313 | entriesByKey: entriesByStatsKey.current, | |
| 1314 | candidateKeys: visibleStatsKeys.current.values, | |
| 1315 | policy: statsPolicy, | |
| 1316 | activeBatches: batches, | |
| 1317 | statsByRow: statsByRowRef.current, | |
| 1318 | }); | |
| 1319 | if (added.length === 0 && current.key === filterKey) return current; | |
| 1320 | return { key: filterKey, batches: [...batches, ...added] }; | |
| 1321 | }); | |
| 1322 | }, [filterKey, groups, statsPolicy]); | |
| 1323 | useEffect(() => { | |
| 1324 | if (statsPolicy !== "visible" || typeof IntersectionObserver === "undefined") return; | |
| 1325 | visibleStatsKeys.current = { key: filterKey, values: new Set() }; | |
| 1326 | const observer = new IntersectionObserver( | |
| 1327 | (observed) => { | |
| 1328 | const visible = visibleStatsKeys.current; | |
| 1329 | if (visible.key !== filterKey) return; | |
| 1330 | let changed = false; | |
| 1331 | const entered = new Set<string>(); | |
| 1332 | for (const item of observed) { | |
| 1333 | const key = (item.target as HTMLElement).dataset.pullRequestStatsKey; | |
| 1334 | if (key === undefined) continue; | |
| 1335 | const wasVisible = visible.values.has(key); | |
| 1336 | if (item.isIntersecting && entriesByStatsKey.current.has(key)) { | |
| 1337 | visible.values.add(key); | |
| 1338 | if (!wasVisible) entered.add(key); | |
| 1339 | } else { | |
| 1340 | visible.values.delete(key); | |
| 1341 | } | |
| 1342 | changed ||= wasVisible !== visible.values.has(key); | |
| 1343 | } | |
| 1344 | if (!changed) return; | |
| 1345 | setStatsTargetState((current) => { | |
| 1346 | const batches = current.key === filterKey ? current.batches : []; | |
| 1347 | const retained = statsPending.current | |
| 1348 | ? batches | |
| 1349 | : retainVisiblePullRequestStatsBatches(batches, visible.values); | |
| 1350 | const added = pullRequestStatsRequestBatches({ | |
| 1351 | entriesByKey: entriesByStatsKey.current, | |
| 1352 | candidateKeys: entered, | |
| 1353 | policy: statsPolicy, | |
| 1354 | activeBatches: batches, | |
| 1355 | statsByRow: statsByRowRef.current, | |
| 1356 | }); | |
| 1357 | if (added.length === 0 && retained.length === batches.length) return current; | |
| 1358 | return { key: filterKey, batches: [...retained, ...added] }; | |
| 1359 | }); | |
| 1360 | }, | |
| 1361 | { root: scrollRef.current, rootMargin: "480px" }, | |
| 1362 | ); | |
| 1363 | statsObserver.current = observer; | |
| 1364 | for (const row of statsRows.current) observer.observe(row); | |
| 1365 | return () => { | |
| 1366 | observer.disconnect(); | |
| 1367 | if (statsObserver.current === observer) statsObserver.current = null; | |
| 1368 | }; | |
| 1369 | }, [filterKey, statsPolicy]); | |
| 1247 | 1370 | const statsQuery = usePullRequestListStats(statsTargets); |
| 1371 | statsPending.current = statsQuery.isPending; | |
| 1372 | useEffect(() => { | |
| 1373 | if (statsPolicy !== "visible" || statsQuery.isPending) return; | |
| 1374 | const visible = visibleStatsKeys.current; | |
| 1375 | setStatsTargetState((current) => { | |
| 1376 | if (current.key !== filterKey || visible.key !== filterKey) return current; | |
| 1377 | const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values); | |
| 1378 | return batches.length === current.batches.length ? current : { key: current.key, batches }; | |
| 1379 | }); | |
| 1380 | }, [filterKey, statsPolicy, statsQuery.isPending]); | |
| 1248 | 1381 | // Adding or removing one row keys a fresh stats query with nothing in it yet, so the counts |
| 1249 | 1382 | // are merged into what is already held rather than rebuilt: every count on screen stays until |
| 1250 | 1383 | // its replacement arrives. |
| 1251 | const [statsByRow, setStatsByRow] = useState<PullRequestDiffStats>(() => new Map()); | |
| 1252 | 1384 | useEffect(() => { |
| 1253 | 1385 | const stats = statsQuery.stats; |
| 1254 | 1386 | if (stats === null) return; |
| ... | ... | @@ -1486,30 +1618,35 @@ function PullRequestsRouteView() { |
| 1486 | 1618 | {group.label} |
| 1487 | 1619 | </h2> |
| 1488 | 1620 | ) : null} |
| 1489 | {group.entries.map((entry) => ( | |
| 1490 | <PullRequestRow | |
| 1491 | key={pullRequestEntryKey(entry)} | |
| 1492 | entry={entry} | |
| 1493 | showProjectTitle | |
| 1494 | showProvider={showProvider} | |
| 1495 | {...(capableEnvironments.length > 1 && | |
| 1496 | environmentLabels.get(entry.environmentId) !== undefined | |
| 1497 | ? { environmentLabel: environmentLabels.get(entry.environmentId)! } | |
| 1498 | : {})} | |
| 1499 | // Ten is the floor the ranking gives a row whose own fields say nothing | |
| 1500 | // about the search: the host matched something this row cannot show. | |
| 1501 | matchedElsewhere={ | |
| 1502 | typedParsed.text.length > 0 && | |
| 1503 | scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE | |
| 1504 | } | |
| 1505 | selected={ | |
| 1506 | selected?.environmentId === entry.environmentId && | |
| 1507 | selected.repository === entry.repository && | |
| 1508 | selected.number === entry.number | |
| 1509 | } | |
| 1510 | onSelect={selectEntry} | |
| 1511 | /> | |
| 1512 | ))} | |
| 1621 | {group.entries.map((entry) => { | |
| 1622 | const entryKey = pullRequestEntryKey(entry); | |
| 1623 | return ( | |
| 1624 | <PullRequestRow | |
| 1625 | key={entryKey} | |
| 1626 | statsKey={entryKey} | |
| 1627 | statsRef={registerStatsRow} | |
| 1628 | entry={entry} | |
| 1629 | showProjectTitle | |
| 1630 | showProvider={showProvider} | |
| 1631 | {...(capableEnvironments.length > 1 && | |
| 1632 | environmentLabels.get(entry.environmentId) !== undefined | |
| 1633 | ? { environmentLabel: environmentLabels.get(entry.environmentId)! } | |
| 1634 | : {})} | |
| 1635 | // Ten is the floor the ranking gives a row whose own fields say nothing | |
| 1636 | // about the search: the host matched something this row cannot show. | |
| 1637 | matchedElsewhere={ | |
| 1638 | typedParsed.text.length > 0 && | |
| 1639 | scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE | |
| 1640 | } | |
| 1641 | selected={ | |
| 1642 | selected?.environmentId === entry.environmentId && | |
| 1643 | selected.repository === entry.repository && | |
| 1644 | selected.number === entry.number | |
| 1645 | } | |
| 1646 | onSelect={selectEntry} | |
| 1647 | /> | |
| 1648 | ); | |
| 1649 | })} | |
| 1513 | 1650 | </div> |
| 1514 | 1651 | ))} |
| 1515 | 1652 | </div> |
| ... | ... | @@ -1648,6 +1785,7 @@ function PullRequestsRouteView() { |
| 1648 | 1785 | pullRequestsSupported && !rightPanelState.isOpen ? openPanelControls : null, |
| 1649 | 1786 | rightPanelOpen: rightPanelState.isOpen, |
| 1650 | 1787 | listBody, |
| 1788 | scrollRef, | |
| 1651 | 1789 | }; |
| 1652 | 1790 | |
| 1653 | 1791 | const activateSurface = (surface: PullRequestSurface) => { |
| ... | ... | @@ -1932,6 +2070,7 @@ function PullRequestsColumn({ |
| 1932 | 2070 | titlebarControls, |
| 1933 | 2071 | rightPanelOpen, |
| 1934 | 2072 | listBody, |
| 2073 | scrollRef, | |
| 1935 | 2074 | }: { |
| 1936 | 2075 | refreshing: boolean; |
| 1937 | 2076 | onRefresh: () => void; |
| ... | ... | @@ -1950,8 +2089,8 @@ function PullRequestsColumn({ |
| 1950 | 2089 | titlebarControls: ReactNode; |
| 1951 | 2090 | rightPanelOpen: boolean; |
| 1952 | 2091 | listBody: ReactNode; |
| 2092 | scrollRef: RefObject<HTMLDivElement | null>; | |
| 1953 | 2093 | }) { |
| 1954 | const scrollRef = useRef<HTMLDivElement | null>(null); | |
| 1955 | 2094 | const markerRef = useRef<HTMLDivElement | null>(null); |
| 1956 | 2095 | const [condensed, setCondensed] = useState(false); |
| 1957 | 2096 | useEffect(() => { |
apps/web/src/state/pullRequests.ts+16-8| ... | ... | @@ -31,7 +31,7 @@ export interface EnvironmentQueryTarget<Input> { |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | interface MergedEnvironmentQueryView<A> { |
| 34 | /** One entry per environment that has answered, in the order the targets were given. */ | |
| 34 | /** One entry per query target that has answered, in the order the targets were given. */ | |
| 35 | 35 | readonly values: ReadonlyArray<readonly [EnvironmentId, A]>; |
| 36 | 36 | /** The first environment that failed. Others may still have answered — this is not fatal. */ |
| 37 | 37 | readonly error: string | null; |
| ... | ... | @@ -78,11 +78,16 @@ function createMergedEnvironmentQuery<Input, A>( |
| 78 | 78 | return function useMergedQuery(targets: ReadonlyArray<EnvironmentQueryTarget<Input>>) { |
| 79 | 79 | const key = JSON.stringify(targets); |
| 80 | 80 | const view = useAtomValue(targets.length === 0 ? empty : family(key)); |
| 81 | const refresh = useCallback(() => { | |
| 82 | for (const target of JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>) { | |
| 83 | appAtomRegistry.refresh(atomFor(target)); | |
| 84 | } | |
| 85 | }, [key]); | |
| 81 | const refresh = useCallback( | |
| 82 | (override?: ReadonlyArray<EnvironmentQueryTarget<Input>>) => { | |
| 83 | const refreshTargets = | |
| 84 | override ?? (JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>); | |
| 85 | for (const target of refreshTargets) { | |
| 86 | appAtomRegistry.refresh(atomFor(target)); | |
| 87 | } | |
| 88 | }, | |
| 89 | [key], | |
| 90 | ); | |
| 86 | 91 | return { ...view, refresh }; |
| 87 | 92 | }; |
| 88 | 93 | } |
| ... | ... | @@ -118,7 +123,10 @@ export function usePullRequestListStats( |
| 118 | 123 | targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>, |
| 119 | 124 | ): { |
| 120 | 125 | readonly stats: ReadonlyArray<EnvironmentPullRequestStat> | null; |
| 121 | readonly refresh: () => void; | |
| 126 | readonly isPending: boolean; | |
| 127 | readonly refresh: ( | |
| 128 | targets?: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>, | |
| 129 | ) => void; | |
| 122 | 130 | } { |
| 123 | 131 | const query = usePullRequestStatsQuery(targets); |
| 124 | 132 | const stats = useMemo( |
| ... | ... | @@ -130,5 +138,5 @@ export function usePullRequestListStats( |
| 130 | 138 | ), |
| 131 | 139 | [query.values], |
| 132 | 140 | ); |
| 133 | return { stats, refresh: query.refresh }; | |
| 141 | return { stats, isPending: query.isPending, refresh: query.refresh }; | |
| 134 | 142 | } |