| author | |
| committer | |
| log | e5a87e8b9ca9db21e0291ddbd54438c5fe56b277 |
| tree | 029246a843de6fcedecc6dd78f1e8e498fd832d1 |
| parent | 2fa5ef4c7bf3aafabe98392d25be7eb86847ce8f |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 239 insertions(+), 24 deletions(-)
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+4-2| ... | ... | @@ -37,6 +37,7 @@ import { |
| 37 | 37 | MenuPopup, |
| 38 | 38 | MenuRadioGroup, |
| 39 | 39 | MenuRadioItem, |
| 40 | MenuRadioItemIndicator, | |
| 40 | 41 | MenuSeparator, |
| 41 | 42 | MenuSub, |
| 42 | 43 | MenuSubPopup, |
| ... | ... | @@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup<Value extends string>({ |
| 205 | 206 | <PullRequestFilterOptionIcon option={option} /> |
| 206 | 207 | <span className="min-w-0 flex-1 truncate">{option.label}</span> |
| 207 | 208 | {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null} |
| 209 | <MenuRadioItemIndicator /> | |
| 208 | 210 | </span> |
| 209 | 211 | </MenuRadioItem> |
| 210 | 212 | ); |
| ... | ... | @@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({ |
| 455 | 457 | readonly environmentId: EnvironmentId; |
| 456 | 458 | readonly title: string; |
| 457 | 459 | readonly workspaceRoot: string; |
| 458 | readonly faviconPath?: string | null; | |
| 459 | readonly projectIcon?: ProjectIconOverride | null; | |
| 460 | readonly faviconPath?: string | null | undefined; | |
| 461 | readonly projectIcon?: ProjectIconOverride | null | undefined; | |
| 460 | 462 | }>; |
| 461 | 463 | projectId: ProjectId | undefined; |
| 462 | 464 | /** |
apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts created+158| ... | ... | @@ -0,0 +1,158 @@ |
| 1 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { findScopedProject } from "./pullRequestList.logic"; | |
| 5 | import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; | |
| 6 | ||
| 7 | const cups = EnvironmentId.make("env-cups"); | |
| 8 | const nucbox = EnvironmentId.make("env-nucbox"); | |
| 9 | const labels = new Map([ | |
| 10 | [cups, "cups"], | |
| 11 | [nucbox, "nucbox-1"], | |
| 12 | ]); | |
| 13 | ||
| 14 | function project( | |
| 15 | id: string, | |
| 16 | environmentId = nucbox, | |
| 17 | canonicalKey: string | null = "github.com/pingdotgg/t3code", | |
| 18 | ) { | |
| 19 | return { | |
| 20 | id: ProjectId.make(id), | |
| 21 | environmentId, | |
| 22 | title: "t3code", | |
| 23 | workspaceRoot: `/work/${id}`, | |
| 24 | repositoryIdentity: canonicalKey === null ? null : { canonicalKey }, | |
| 25 | faviconPath: `${id}/favicon.png`, | |
| 26 | }; | |
| 27 | } | |
| 28 | ||
| 29 | describe("pull request project filter choices", () => { | |
| 30 | it("collapses three checkouts on one server without dropping another server's copy", () => { | |
| 31 | const projects = [ | |
| 32 | project("main"), | |
| 33 | project("worktree-1"), | |
| 34 | project("worktree-2"), | |
| 35 | project("main", cups), | |
| 36 | ]; | |
| 37 | ||
| 38 | const choices = pullRequestFilterProjects(projects, labels); | |
| 39 | ||
| 40 | expect(choices.map(({ id, environmentId, title }) => ({ id, environmentId, title }))).toEqual([ | |
| 41 | { id: "main", environmentId: cups, title: "t3code · cups" }, | |
| 42 | { id: "main", environmentId: nucbox, title: "t3code · nucbox-1" }, | |
| 43 | ]); | |
| 44 | expect(choices[1]?.workspaceRoot).toBe("/work/main"); | |
| 45 | expect(choices[1]?.faviconPath).toBe("main/favicon.png"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("keeps a saved worktree selection as the repository's only choice", () => { | |
| 49 | const projects = [project("main"), project("worktree"), project("worktree", cups)]; | |
| 50 | const selected = findScopedProject(projects, nucbox, "worktree"); | |
| 51 | ||
| 52 | const choices = pullRequestFilterProjects(projects, labels, selected); | |
| 53 | ||
| 54 | expect(choices.filter((choice) => choice.environmentId === nucbox)).toEqual([ | |
| 55 | { ...projects[1], title: "t3code · nucbox-1" }, | |
| 56 | ]); | |
| 57 | expect(findScopedProject(choices, nucbox, "worktree")).toBeDefined(); | |
| 58 | expect(findScopedProject(choices, nucbox, "main")).toBeUndefined(); | |
| 59 | expect(findScopedProject(choices, cups, "worktree")).toBeDefined(); | |
| 60 | }); | |
| 61 | ||
| 62 | it("matches canonical repositories regardless of casing", () => { | |
| 63 | const main = project("main"); | |
| 64 | const worktree = project("worktree", nucbox, "GitHub.com/PingDotGG/T3Code"); | |
| 65 | ||
| 66 | expect(pullRequestFilterProjects([main, worktree], labels)).toEqual([main]); | |
| 67 | }); | |
| 68 | ||
| 69 | it("does not add a server suffix after duplicate checkouts have collapsed", () => { | |
| 70 | const main = project("main"); | |
| 71 | ||
| 72 | expect(pullRequestFilterProjects([main, project("worktree")], labels)).toEqual([main]); | |
| 73 | expect(main.title).toBe("t3code"); | |
| 74 | }); | |
| 75 | ||
| 76 | it("distinguishes same-named repositories on one server by checkout path", () => { | |
| 77 | const projects = [ | |
| 78 | project("upstream"), | |
| 79 | project("fork", nucbox, "github.com/juliusmarminge/t3code"), | |
| 80 | ]; | |
| 81 | ||
| 82 | const choices = pullRequestFilterProjects(projects, labels); | |
| 83 | ||
| 84 | expect(choices.map((choice) => choice.title)).toEqual([ | |
| 85 | "t3code · nucbox-1 · /work/fork", | |
| 86 | "t3code · nucbox-1 · /work/upstream", | |
| 87 | ]); | |
| 88 | }); | |
| 89 | ||
| 90 | it("keeps repositories on different hosts separate", () => { | |
| 91 | const choices = pullRequestFilterProjects( | |
| 92 | [project("github"), project("enterprise", nucbox, "git.example.com/pingdotgg/t3code")], | |
| 93 | labels, | |
| 94 | ); | |
| 95 | ||
| 96 | expect(choices.map((choice) => choice.id)).toEqual(["enterprise", "github"]); | |
| 97 | }); | |
| 98 | ||
| 99 | it("does not merge projects whose repository identity is unknown", () => { | |
| 100 | const choices = pullRequestFilterProjects( | |
| 101 | [project("first", nucbox, null), project("second", nucbox, null)], | |
| 102 | labels, | |
| 103 | ); | |
| 104 | ||
| 105 | expect(choices.map((choice) => choice.title)).toEqual([ | |
| 106 | "t3code · nucbox-1 · /work/first", | |
| 107 | "t3code · nucbox-1 · /work/second", | |
| 108 | ]); | |
| 109 | }); | |
| 110 | ||
| 111 | it("distinguishes servers with the same display name and checkout path", () => { | |
| 112 | const first = project("main"); | |
| 113 | const second = project("main", cups); | |
| 114 | const repeatedLabels = new Map([ | |
| 115 | [cups, "nucbox-1"], | |
| 116 | [nucbox, "nucbox-1"], | |
| 117 | ]); | |
| 118 | ||
| 119 | const choices = pullRequestFilterProjects([first, second], repeatedLabels); | |
| 120 | ||
| 121 | expect(choices.map((choice) => choice.title)).toEqual([ | |
| 122 | "t3code · nucbox-1 · /work/main · env-cups", | |
| 123 | "t3code · nucbox-1 · /work/main · env-nucbox", | |
| 124 | ]); | |
| 125 | }); | |
| 126 | ||
| 127 | it("uses the environment id when its label is unavailable", () => { | |
| 128 | const choices = pullRequestFilterProjects( | |
| 129 | [project("main"), project("remote", cups)], | |
| 130 | new Map(), | |
| 131 | ); | |
| 132 | ||
| 133 | expect(choices.map((choice) => choice.title)).toEqual([ | |
| 134 | "t3code · env-cups", | |
| 135 | "t3code · env-nucbox", | |
| 136 | ]); | |
| 137 | }); | |
| 138 | ||
| 139 | it("can distinguish unresolved project records that also share a checkout path", () => { | |
| 140 | const first = project("first", nucbox, null); | |
| 141 | const second = { ...project("second", nucbox, null), workspaceRoot: first.workspaceRoot }; | |
| 142 | ||
| 143 | const choices = pullRequestFilterProjects([first, second], labels); | |
| 144 | ||
| 145 | expect(choices.map((choice) => choice.title)).toEqual([ | |
| 146 | "t3code · nucbox-1 · /work/first · env-nucbox · first", | |
| 147 | "t3code · nucbox-1 · /work/first · env-nucbox · second", | |
| 148 | ]); | |
| 149 | }); | |
| 150 | ||
| 151 | it("leaves unrelated names unchanged and orders them alphabetically", () => { | |
| 152 | const app = { ...project("app"), title: "Zebra" }; | |
| 153 | const tools = { ...project("tools", nucbox, "github.com/acme/tools"), title: "Alpha" }; | |
| 154 | ||
| 155 | expect(pullRequestFilterProjects([app, tools], labels)).toEqual([tools, app]); | |
| 156 | expect(pullRequestFilterProjects([], labels)).toEqual([]); | |
| 157 | }); | |
| 158 | }); |
apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; | |
| 4 | ||
| 5 | interface FilterProject extends AssignableProject { | |
| 6 | readonly title: string; | |
| 7 | readonly workspaceRoot: string; | |
| 8 | } | |
| 9 | ||
| 10 | function distinguishTitles<Project extends FilterProject>( | |
| 11 | projects: ReadonlyArray<Project>, | |
| 12 | suffix: (project: Project) => string, | |
| 13 | ) { | |
| 14 | const counts = new Map<string, number>(); | |
| 15 | for (const project of projects) { | |
| 16 | counts.set(project.title, (counts.get(project.title) ?? 0) + 1); | |
| 17 | } | |
| 18 | return projects.map((project) => | |
| 19 | (counts.get(project.title) ?? 0) > 1 | |
| 20 | ? { ...project, title: `${project.title} · ${suffix(project)}` } | |
| 21 | : project, | |
| 22 | ); | |
| 23 | } | |
| 24 | ||
| 25 | /** One choice per repository per server, retaining the selected checkout for saved scopes. */ | |
| 26 | export function pullRequestFilterProjects<Project extends FilterProject>( | |
| 27 | projects: ReadonlyArray<Project>, | |
| 28 | environmentLabels: ReadonlyMap<EnvironmentId, string>, | |
| 29 | selectedProject?: Pick<AssignableProject, "id" | "environmentId">, | |
| 30 | ) { | |
| 31 | const byRepository = new Map<string, Project>(); | |
| 32 | for (const project of projects) { | |
| 33 | const repository = project.repositoryIdentity?.canonicalKey?.toLowerCase(); | |
| 34 | const key = JSON.stringify([ | |
| 35 | project.environmentId, | |
| 36 | repository ? ["repository", repository] : ["project", project.id], | |
| 37 | ]); | |
| 38 | const selected = | |
| 39 | project.id === selectedProject?.id && project.environmentId === selectedProject.environmentId; | |
| 40 | if (!byRepository.has(key) || selected) byRepository.set(key, project); | |
| 41 | } | |
| 42 | ||
| 43 | const byServer = distinguishTitles( | |
| 44 | [...byRepository.values()], | |
| 45 | (project) => environmentLabels.get(project.environmentId) ?? project.environmentId, | |
| 46 | ); | |
| 47 | const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); | |
| 48 | // Separate environments can share both their display name and their checkout path. | |
| 49 | const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); | |
| 50 | return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => | |
| 51 | left.title.localeCompare(right.title), | |
| 52 | ); | |
| 53 | } |
apps/web/src/components/ui/menu.tsx+19-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Menu as MenuPrimitive } from "@base-ui/react/menu"; |
| 4 | import { ChevronRightIcon } from "lucide-react"; | |
| 4 | import { CheckIcon, ChevronRightIcon } from "lucide-react"; | |
| 5 | 5 | import type * as React from "react"; |
| 6 | 6 | |
| 7 | 7 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -177,6 +177,23 @@ function MenuRadioItem({ |
| 177 | 177 | ); |
| 178 | 178 | } |
| 179 | 179 | |
| 180 | function MenuRadioItemIndicator({ | |
| 181 | className, | |
| 182 | children, | |
| 183 | ...props | |
| 184 | }: MenuPrimitive.RadioItemIndicator.Props) { | |
| 185 | return ( | |
| 186 | <MenuPrimitive.RadioItemIndicator | |
| 187 | aria-hidden | |
| 188 | className={cn("flex shrink-0", className)} | |
| 189 | data-slot="menu-radio-item-indicator" | |
| 190 | {...props} | |
| 191 | > | |
| 192 | {children ?? <CheckIcon className="size-3.5" />} | |
| 193 | </MenuPrimitive.RadioItemIndicator> | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 180 | 197 | function MenuGroupLabel({ |
| 181 | 198 | className, |
| 182 | 199 | inset, |
| ... | ... | @@ -300,6 +317,7 @@ export { |
| 300 | 317 | MenuRadioGroup as DropdownMenuRadioGroup, |
| 301 | 318 | MenuRadioItem, |
| 302 | 319 | MenuRadioItem as DropdownMenuRadioItem, |
| 320 | MenuRadioItemIndicator, | |
| 303 | 321 | MenuGroupLabel, |
| 304 | 322 | MenuGroupLabel as DropdownMenuLabel, |
| 305 | 323 | MenuSeparator, |
apps/web/src/routes/_chat.pull-requests.tsx+5-21| ... | ... | @@ -86,6 +86,7 @@ import { |
| 86 | 86 | writePullRequestListPreferences, |
| 87 | 87 | } from "../components/pullRequest/pullRequestListPreferences"; |
| 88 | 88 | import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; |
| 89 | import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic"; | |
| 89 | 90 | import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; |
| 90 | 91 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; |
| 91 | 92 | import { |
| ... | ... | @@ -356,27 +357,6 @@ function PullRequestsRouteView() { |
| 356 | 357 | ), |
| 357 | 358 | [environments], |
| 358 | 359 | ); |
| 359 | const scopedProjects = useMemo(() => { | |
| 360 | // Two machines can hold the same repository, so a title the workspace carries twice is told | |
| 361 | // apart by the environment it lives on rather than left as two identical rows. | |
| 362 | const titleCounts = new Map<string, number>(); | |
| 363 | for (const project of projects) { | |
| 364 | titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1); | |
| 365 | } | |
| 366 | return projects | |
| 367 | .map((project) => ({ | |
| 368 | id: project.id, | |
| 369 | environmentId: project.environmentId, | |
| 370 | title: | |
| 371 | (titleCounts.get(project.title) ?? 0) > 1 | |
| 372 | ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}` | |
| 373 | : project.title, | |
| 374 | workspaceRoot: project.workspaceRoot, | |
| 375 | faviconPath: project.faviconPath ?? null, | |
| 376 | projectIcon: project.projectIcon ?? null, | |
| 377 | })) | |
| 378 | .toSorted((left, right) => left.title.localeCompare(right.title)); | |
| 379 | }, [environmentLabels, projects]); | |
| 380 | 360 | // The scope the URL asks for, once the environments have had their say about whether it exists. |
| 381 | 361 | const scopedProjectId = useMemo( |
| 382 | 362 | () => resolveProjectScope(search.projectId, projects, projectsKnown), |
| ... | ... | @@ -386,6 +366,10 @@ function PullRequestsRouteView() { |
| 386 | 366 | () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), |
| 387 | 367 | [projects, scopedEnvironmentId, scopedProjectId], |
| 388 | 368 | ); |
| 369 | const scopedProjects = useMemo( | |
| 370 | () => pullRequestFilterProjects(projects, environmentLabels, scopedProject), | |
| 371 | [environmentLabels, projects, scopedProject], | |
| 372 | ); | |
| 389 | 373 | |
| 390 | 374 | // A link from a thread or the sidebar only knows the repository, so the owning project is |
| 391 | 375 | // resolved here; an explicit `projectId` in the URL still wins. |