| 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,6 +37,7 @@ import { |
| 37 | MenuPopup, | 37 | MenuPopup, |
| 38 | MenuRadioGroup, | 38 | MenuRadioGroup, |
| 39 | MenuRadioItem, | 39 | MenuRadioItem, |
| 40 | MenuRadioItemIndicator, | ||
| 40 | MenuSeparator, | 41 | MenuSeparator, |
| 41 | MenuSub, | 42 | MenuSub, |
| 42 | MenuSubPopup, | 43 | MenuSubPopup, |
| ... | @@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup<Value extends string>({ | ... | @@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup<Value extends string>({ |
| 205 | <PullRequestFilterOptionIcon option={option} /> | 206 | <PullRequestFilterOptionIcon option={option} /> |
| 206 | <span className="min-w-0 flex-1 truncate">{option.label}</span> | 207 | <span className="min-w-0 flex-1 truncate">{option.label}</span> |
| 207 | {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null} | 208 | {option.unavailable ? <span className="shrink-0">· Unavailable</span> : null} |
| 209 | <MenuRadioItemIndicator /> | ||
| 208 | </span> | 210 | </span> |
| 209 | </MenuRadioItem> | 211 | </MenuRadioItem> |
| 210 | ); | 212 | ); |
| ... | @@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({ | ... | @@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({ |
| 455 | readonly environmentId: EnvironmentId; | 457 | readonly environmentId: EnvironmentId; |
| 456 | readonly title: string; | 458 | readonly title: string; |
| 457 | readonly workspaceRoot: string; | 459 | readonly workspaceRoot: string; |
| 458 | readonly faviconPath?: string | null; | 460 | readonly faviconPath?: string | null | undefined; |
| 459 | readonly projectIcon?: ProjectIconOverride | null; | 461 | readonly projectIcon?: ProjectIconOverride | null | undefined; |
| 460 | }>; | 462 | }>; |
| 461 | projectId: ProjectId | undefined; | 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,7 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { Menu as MenuPrimitive } from "@base-ui/react/menu"; | 3 | import { Menu as MenuPrimitive } from "@base-ui/react/menu"; |
| 4 | import { ChevronRightIcon } from "lucide-react"; | 4 | import { CheckIcon, ChevronRightIcon } from "lucide-react"; |
| 5 | import type * as React from "react"; | 5 | import type * as React from "react"; |
| 6 | 6 | ||
| 7 | import { cn } from "~/lib/utils"; | 7 | import { cn } from "~/lib/utils"; |
| ... | @@ -177,6 +177,23 @@ function MenuRadioItem({ | ... | @@ -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 | function MenuGroupLabel({ | 197 | function MenuGroupLabel({ |
| 181 | className, | 198 | className, |
| 182 | inset, | 199 | inset, |
| ... | @@ -300,6 +317,7 @@ export { | ... | @@ -300,6 +317,7 @@ export { |
| 300 | MenuRadioGroup as DropdownMenuRadioGroup, | 317 | MenuRadioGroup as DropdownMenuRadioGroup, |
| 301 | MenuRadioItem, | 318 | MenuRadioItem, |
| 302 | MenuRadioItem as DropdownMenuRadioItem, | 319 | MenuRadioItem as DropdownMenuRadioItem, |
| 320 | MenuRadioItemIndicator, | ||
| 303 | MenuGroupLabel, | 321 | MenuGroupLabel, |
| 304 | MenuGroupLabel as DropdownMenuLabel, | 322 | MenuGroupLabel as DropdownMenuLabel, |
| 305 | MenuSeparator, | 323 | MenuSeparator, |
apps/web/src/routes/_chat.pull-requests.tsx+5-21| ... | @@ -86,6 +86,7 @@ import { | ... | @@ -86,6 +86,7 @@ import { |
| 86 | writePullRequestListPreferences, | 86 | writePullRequestListPreferences, |
| 87 | } from "../components/pullRequest/pullRequestListPreferences"; | 87 | } from "../components/pullRequest/pullRequestListPreferences"; |
| 88 | import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; | 88 | import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; |
| 89 | import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic"; | ||
| 89 | import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; | 90 | import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; |
| 90 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; | 91 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; |
| 91 | import { | 92 | import { |
| ... | @@ -356,27 +357,6 @@ function PullRequestsRouteView() { | ... | @@ -356,27 +357,6 @@ function PullRequestsRouteView() { |
| 356 | ), | 357 | ), |
| 357 | [environments], | 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 | // The scope the URL asks for, once the environments have had their say about whether it exists. | 360 | // The scope the URL asks for, once the environments have had their say about whether it exists. |
| 381 | const scopedProjectId = useMemo( | 361 | const scopedProjectId = useMemo( |
| 382 | () => resolveProjectScope(search.projectId, projects, projectsKnown), | 362 | () => resolveProjectScope(search.projectId, projects, projectsKnown), |
| ... | @@ -386,6 +366,10 @@ function PullRequestsRouteView() { | ... | @@ -386,6 +366,10 @@ function PullRequestsRouteView() { |
| 386 | () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), | 366 | () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), |
| 387 | [projects, scopedEnvironmentId, scopedProjectId], | 367 | [projects, scopedEnvironmentId, scopedProjectId], |
| 388 | ); | 368 | ); |
| 369 | const scopedProjects = useMemo( | ||
| 370 | () => pullRequestFilterProjects(projects, environmentLabels, scopedProject), | ||
| 371 | [environmentLabels, projects, scopedProject], | ||
| 372 | ); | ||
| 389 | 373 | ||
| 390 | // A link from a thread or the sidebar only knows the repository, so the owning project is | 374 | // A link from a thread or the sidebar only knows the repository, so the owning project is |
| 391 | // resolved here; an explicit `projectId` in the URL still wins. | 375 | // resolved here; an explicit `projectId` in the URL still wins. |