| author | |
| committer | |
| log | 5070a83a3248c537d3baaf063e719f179c49bb52 |
| tree | bef860d3bc0fec23e4eec6a15fd885b618bb9028 |
| parent | 80664d5266a1be8c5989bac352ee5e060bbbdf4a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>7 files changed, 130 insertions(+), 3 deletions(-)
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+2| ... | @@ -109,6 +109,7 @@ import { | ... | @@ -109,6 +109,7 @@ import { |
| 109 | import { PullRequestMergeControls } from "./PullRequestMergeControls"; | 109 | import { PullRequestMergeControls } from "./PullRequestMergeControls"; |
| 110 | import { PullRequestStackCard } from "./PullRequestStackCard"; | 110 | import { PullRequestStackCard } from "./PullRequestStackCard"; |
| 111 | import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack"; | 111 | import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack"; |
| 112 | import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback"; | ||
| 112 | 113 | ||
| 113 | const EMPTY_IDS: readonly string[] = []; | 114 | const EMPTY_IDS: readonly string[] = []; |
| 114 | 115 | ||
| ... | @@ -286,6 +287,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -286,6 +287,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 286 | const insets = useSafeAreaInsets(); | 287 | const insets = useSafeAreaInsets(); |
| 287 | const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom); | 288 | const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom); |
| 288 | const params = route.params; | 289 | const params = route.params; |
| 290 | usePullRequestEnvironmentFallback(params); | ||
| 289 | const connected = usePullRequestConnected(params.environmentId); | 291 | const connected = usePullRequestConnected(params.environmentId); |
| 290 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); | 292 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); |
| 291 | // Keep each tab's native list and measured rows alive when it is hidden. | 293 | // Keep each tab's native list and measured rows alive when it is hidden. |
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+3-1| ... | @@ -142,6 +142,7 @@ export function PullRequestsScreen( | ... | @@ -142,6 +142,7 @@ export function PullRequestsScreen( |
| 142 | ); | 142 | ); |
| 143 | const sidebar = props.sidebar === true || primaryColumn !== null; | 143 | const sidebar = props.sidebar === true || primaryColumn !== null; |
| 144 | const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey; | 144 | const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey; |
| 145 | const connectedKey = connectedTargets.map((target) => target.environmentId).join("\n"); | ||
| 145 | const entries = useMemo( | 146 | const entries = useMemo( |
| 146 | () => | 147 | () => |
| 147 | filterPullRequestList( | 148 | filterPullRequestList( |
| ... | @@ -150,8 +151,9 @@ export function PullRequestsScreen( | ... | @@ -150,8 +151,9 @@ export function PullRequestsScreen( |
| 150 | result.data?.viewers ?? {}, | 151 | result.data?.viewers ?? {}, |
| 151 | query, | 152 | query, |
| 152 | result.data.localSearchHosts, | 153 | result.data.localSearchHosts, |
| 154 | new Set(connectedKey.split("\n")), | ||
| 153 | ), | 155 | ), |
| 154 | [result.entries, controls, query, result.data], | 156 | [result.entries, controls, query, result.data, connectedKey], |
| 155 | ); | 157 | ); |
| 156 | const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]); | 158 | const facets = useMemo(() => pullRequestListFacets(result.entries), [result.entries]); |
| 157 | const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets }); | 159 | const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets }); |
apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.test.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; | ||
| 3 | import { pullRequestFallbackEnvironment } from "./pullRequestEnvironmentFallback"; | ||
| 4 | |||
| 5 | const project = (environmentId: string, id: string, canonicalKey: string) => | ||
| 6 | ({ | ||
| 7 | environmentId, | ||
| 8 | id, | ||
| 9 | repositoryIdentity: { canonicalKey }, | ||
| 10 | }) as unknown as EnvironmentProject; | ||
| 11 | |||
| 12 | const projects = [ | ||
| 13 | project("laptop", "t3", "github.com/pingdotgg/t3code"), | ||
| 14 | project("desktop", "other", "github.com/pingdotgg/other"), | ||
| 15 | project("server", "t3-copy", "github.com/pingdotgg/t3code"), | ||
| 16 | ]; | ||
| 17 | const target = { | ||
| 18 | environmentId: "laptop", | ||
| 19 | projectId: "t3", | ||
| 20 | repository: "pingdotgg/t3code", | ||
| 21 | host: "github.com", | ||
| 22 | }; | ||
| 23 | |||
| 24 | describe("pullRequestFallbackEnvironment", () => { | ||
| 25 | it("moves to a connected copy of the repository when the original is down", () => { | ||
| 26 | expect( | ||
| 27 | pullRequestFallbackEnvironment(target, projects, new Set(["desktop", "server"])), | ||
| 28 | ).toEqual({ environmentId: "server", projectId: "t3-copy" }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("stays put while the original is connected or nothing else holds the repository", () => { | ||
| 32 | expect(pullRequestFallbackEnvironment(target, projects, new Set(["laptop", "server"]))).toBe( | ||
| 33 | null, | ||
| 34 | ); | ||
| 35 | expect(pullRequestFallbackEnvironment(target, projects, new Set(["desktop"]))).toBe(null); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("derives the host from the original project when the route has none", () => { | ||
| 39 | const { host: _host, ...hostless } = target; | ||
| 40 | expect(pullRequestFallbackEnvironment(hostless, projects, new Set(["server"]))).toEqual({ | ||
| 41 | environmentId: "server", | ||
| 42 | projectId: "t3-copy", | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | }); | ||
apps/mobile/src/features/pull-requests/pullRequestEnvironmentFallback.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; | ||
| 2 | |||
| 3 | export type PullRequestEnvironmentTarget = { | ||
| 4 | readonly environmentId: string; | ||
| 5 | readonly projectId: string; | ||
| 6 | readonly repository: string; | ||
| 7 | readonly host?: string; | ||
| 8 | }; | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Another connected environment holding the pull request's repository, for when the one it was | ||
| 12 | * opened in is unreachable. Null while the original is connected or no other copy exists. | ||
| 13 | */ | ||
| 14 | export function pullRequestFallbackEnvironment( | ||
| 15 | target: PullRequestEnvironmentTarget, | ||
| 16 | projects: ReadonlyArray<EnvironmentProject>, | ||
| 17 | connectedEnvironmentIds: ReadonlySet<string>, | ||
| 18 | ) { | ||
| 19 | if (connectedEnvironmentIds.has(target.environmentId)) return null; | ||
| 20 | const own = projects.find( | ||
| 21 | (project) => project.environmentId === target.environmentId && project.id === target.projectId, | ||
| 22 | ); | ||
| 23 | const host = target.host ?? own?.repositoryIdentity?.canonicalKey.split("/")[0]; | ||
| 24 | if (!host) return null; | ||
| 25 | const key = `${host}/${target.repository}`.toLowerCase(); | ||
| 26 | const copy = projects.find( | ||
| 27 | (project) => | ||
| 28 | project.environmentId !== target.environmentId && | ||
| 29 | connectedEnvironmentIds.has(project.environmentId) && | ||
| 30 | project.repositoryIdentity?.canonicalKey.toLowerCase() === key, | ||
| 31 | ); | ||
| 32 | return copy ? { environmentId: String(copy.environmentId), projectId: String(copy.id) } : null; | ||
| 33 | } | ||
apps/mobile/src/features/pull-requests/pullRequestList.test.ts+13| ... | @@ -333,6 +333,19 @@ describe("mobile PR scan and filters", () => { | ... | @@ -333,6 +333,19 @@ describe("mobile PR scan and filters", () => { |
| 333 | ), | 333 | ), |
| 334 | ).toHaveLength(0); | 334 | ).toHaveLength(0); |
| 335 | }); | 335 | }); |
| 336 | it("lists a shared repository's row from a connected environment over an offline one", () => { | ||
| 337 | const rows = [ | ||
| 338 | { ...entry(1), environmentId: "laptop" }, | ||
| 339 | { ...entry(1), environmentId: "server" }, | ||
| 340 | ]; | ||
| 341 | const pick = (connected: ReadonlySet<string>) => | ||
| 342 | filterPullRequestList(rows, DEFAULT_PR_LIST_CONTROLS, {}, "", new Set(), connected).map( | ||
| 343 | (row) => row.environmentId, | ||
| 344 | ); | ||
| 345 | expect(pick(new Set(["server"]))).toEqual(["server"]); | ||
| 346 | expect(pick(new Set(["laptop", "server"]))).toEqual(["laptop"]); | ||
| 347 | expect(pick(new Set())).toEqual(["laptop"]); | ||
| 348 | }); | ||
| 336 | it("sorts loaded rows and builds unique author and label options", () => { | 349 | it("sorts loaded rows and builds unique author and label options", () => { |
| 337 | const rows = [ | 350 | const rows = [ |
| 338 | entry(1, { labels: [{ name: "bug", color: null }] }), | 351 | entry(1, { labels: [{ name: "bug", color: null }] }), |
apps/mobile/src/features/pull-requests/pullRequestList.ts+8-2| ... | @@ -171,6 +171,7 @@ export function filterPullRequestList< | ... | @@ -171,6 +171,7 @@ export function filterPullRequestList< |
| 171 | viewers: PullRequestListResult["viewers"], | 171 | viewers: PullRequestListResult["viewers"], |
| 172 | query: string, | 172 | query: string, |
| 173 | localSearchHosts: ReadonlySet<string>, | 173 | localSearchHosts: ReadonlySet<string>, |
| 174 | connectedEnvironmentIds?: ReadonlySet<string>, | ||
| 174 | ) { | 175 | ) { |
| 175 | const normalize = (value: string) => value.toLowerCase(); | 176 | const normalize = (value: string) => value.toLowerCase(); |
| 176 | const words = query.toLowerCase().split(/\s+/).filter(Boolean); | 177 | const words = query.toLowerCase().split(/\s+/).filter(Boolean); |
| ... | @@ -210,11 +211,16 @@ export function filterPullRequestList< | ... | @@ -210,11 +211,16 @@ export function filterPullRequestList< |
| 210 | } | 211 | } |
| 211 | return true; | 212 | return true; |
| 212 | }); | 213 | }); |
| 213 | // The same repository can be available on multiple machines. Pick its first matching source. | 214 | // The same repository can be available on multiple machines. Pick its first matching source, |
| 215 | // unless that machine is unreachable and another one can answer: its rows may only be cached. | ||
| 216 | const reachable = (entry: Entry) => | ||
| 217 | !connectedEnvironmentIds || | ||
| 218 | (entry.environmentId !== undefined && connectedEnvironmentIds.has(entry.environmentId)); | ||
| 214 | const unique = new Map<string, Entry>(); | 219 | const unique = new Map<string, Entry>(); |
| 215 | for (const entry of filtered) { | 220 | for (const entry of filtered) { |
| 216 | const key = pullRequestListKey(entry); | 221 | const key = pullRequestListKey(entry); |
| 217 | if (!unique.has(key)) unique.set(key, entry); | 222 | const held = unique.get(key); |
| 223 | if (!held || (!reachable(held) && reachable(entry))) unique.set(key, entry); | ||
| 218 | } | 224 | } |
| 219 | return [...unique.values()].sort((a, b) => | 225 | return [...unique.values()].sort((a, b) => |
| 220 | controls.sort === "created" | 226 | controls.sort === "created" |
apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { useNavigation } from "@react-navigation/native"; | ||
| 2 | import { useEffect, useMemo } from "react"; | ||
| 3 | |||
| 4 | import { useProjects } from "../../state/entities"; | ||
| 5 | import { useWorkspaceEnvironments } from "../../state/workspace"; | ||
| 6 | import { | ||
| 7 | pullRequestFallbackEnvironment, | ||
| 8 | type PullRequestEnvironmentTarget, | ||
| 9 | } from "./pullRequestEnvironmentFallback"; | ||
| 10 | |||
| 11 | /** Move a pull request route to a connected copy of its repository when its own is down. */ | ||
| 12 | export function usePullRequestEnvironmentFallback(params: PullRequestEnvironmentTarget) { | ||
| 13 | const navigation = useNavigation(); | ||
| 14 | const projects = useProjects(); | ||
| 15 | const connected = useWorkspaceEnvironments() | ||
| 16 | .filter((environment) => environment.connectionState === "connected") | ||
| 17 | .map((environment) => String(environment.environmentId)) | ||
| 18 | .join("\n"); | ||
| 19 | const fallback = useMemo( | ||
| 20 | () => pullRequestFallbackEnvironment(params, projects, new Set(connected.split("\n"))), | ||
| 21 | [params, projects, connected], | ||
| 22 | ); | ||
| 23 | useEffect(() => { | ||
| 24 | if (fallback) navigation.setParams(fallback); | ||
| 25 | }, [fallback, navigation]); | ||
| 26 | } | ||