- Preserve Codex turn identity while suppressing duplicate diff notifications - Optimize settlement projections and isolate thread visit handling - Add concurrency and regression coverage across server and mobile
Showing the changes to apps/web/src (6 of 167 files). Show all
Unified · Split
| @@ -1186,7 +1186,14 @@ function OpenCommandPaletteDialog(props: { · 1185 unmodified lines | ||
| 1186 | 1186 | projectTitleById, |
| 1187 | 1187 | sortOrder: clientSettings.sidebarThreadSortOrder, |
| 1188 | 1188 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, |
| 1189 | − renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />, | |
| 1189 | + renderLeadingContent: (thread) => ( | |
| 1190 | + <ThreadRowLeadingStatus | |
| 1191 | + thread={thread} | |
| 1192 | + snapshot={changeRequestSnapshotByKey.get( | |
| 1193 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1194 | + )} | |
| 1195 | + /> | |
| 1196 | + ), | |
| 1190 | 1197 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, |
| 1191 | 1198 | renderDescription: (thread, { projectTitle }) => { |
| 1192 | 1199 | const modelInstanceId = |
| @@ -1238,6 +1245,7 @@ function OpenCommandPaletteDialog(props: { · 45 unmodified lines | ||
| 1238 | 1245 | }), |
| 1239 | 1246 | [ |
| 1240 | 1247 | activeThreadId, |
| 1248 | + changeRequestSnapshotByKey, | |
| 1241 | 1249 | clientSettings.sidebarThreadSortOrder, |
| 1242 | 1250 | navigate, |
| 1243 | 1251 | projectCwdById, |
| @@ -0,0 +1,180 @@ | ||
| 1 | +import { ProjectId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | +import { act, type ComponentProps, type ReactNode } from "react"; | |
| 3 | +import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 4 | +import { afterEach, expect, it, vi } from "vite-plus/test"; | |
| 5 | + | |
| 6 | +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | |
| 7 | +import { makeThreadFixture } from "../test-fixtures"; | |
| 8 | + | |
| 9 | +const state = vi.hoisted(() => ({ queries: [] as string[] })); | |
| 10 | + | |
| 11 | +vi.mock("../state/entities", () => ({ useProject: () => null })); | |
| 12 | +vi.mock("../uiStateStore", () => ({ useUiStateStore: () => undefined })); | |
| 13 | +vi.mock("../state/vcs", async () => { | |
| 14 | + const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | |
| 15 | + const statuses = Atom.family((cwd: string) => | |
| 16 | + Atom.make(() => { | |
| 17 | + state.queries.push(`vcs:${cwd}`); | |
| 18 | + return AsyncResult.success({ | |
| 19 | + isRepo: true, | |
| 20 | + hasPrimaryRemote: true, | |
| 21 | + isDefaultRef: false, | |
| 22 | + refName: "feature/test", | |
| 23 | + hasWorkingTreeChanges: false, | |
| 24 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 25 | + hasUpstream: true, | |
| 26 | + aheadCount: 0, | |
| 27 | + behindCount: 0, | |
| 28 | + pr: { | |
| 29 | + number: 42, | |
| 30 | + title: "Observed worktree PR", | |
| 31 | + url: "https://github.com/example/repo/pull/42", | |
| 32 | + baseRef: "main", | |
| 33 | + headRef: "feature/test", | |
| 34 | + state: "open", | |
| 35 | + }, | |
| 36 | + } satisfies VcsStatusResult); | |
| 37 | + }), | |
| 38 | + ); | |
| 39 | + return { | |
| 40 | + vcsEnvironment: { status: ({ input }: { input: { cwd: string } }) => statuses(input.cwd) }, | |
| 41 | + }; | |
| 42 | +}); | |
| 43 | +vi.mock("../state/pullRequests", async (importOriginal) => { | |
| 44 | + const original = await importOriginal<typeof import("../state/pullRequests")>(); | |
| 45 | + const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | |
| 46 | + const { ProjectId } = await import("@t3tools/contracts"); | |
| 47 | + const summaries = Atom.family((number: number) => | |
| 48 | + Atom.make(() => { | |
| 49 | + state.queries.push(`pr:${number}`); | |
| 50 | + return AsyncResult.success({ | |
| 51 | + provider: "github" as const, | |
| 52 | + projectId: ProjectId.make("project-test"), | |
| 53 | + repository: "example/repo", | |
| 54 | + number, | |
| 55 | + title: "Observed linked PR", | |
| 56 | + url: `https://github.com/example/repo/pull/${number}`, | |
| 57 | + state: "open" as const, | |
| 58 | + headBranch: "feature/test", | |
| 59 | + baseBranch: "main", | |
| 60 | + updatedAt: "2026-09-04T00:00:00.000Z", | |
| 61 | + }); | |
| 62 | + }), | |
| 63 | + ); | |
| 64 | + return { | |
| 65 | + ...original, | |
| 66 | + linkedPullRequestDetailAtom: ({ input }: { input: { number: number } }) => | |
| 67 | + summaries(input.number), | |
| 68 | + }; | |
| 69 | +}); | |
| 70 | +vi.mock("./ui/tooltip", async () => { | |
| 71 | + const { cloneElement, isValidElement } = await import("react"); | |
| 72 | + return { | |
| 73 | + Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 74 | + TooltipTrigger({ | |
| 75 | + render, | |
| 76 | + children, | |
| 77 | + }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) { | |
| 78 | + return isValidElement(render) ? cloneElement(render, undefined, children) : children; | |
| 79 | + }, | |
| 80 | + TooltipPopup: () => null, | |
| 81 | + }; | |
| 82 | +}); | |
| 83 | + | |
| 84 | +import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; | |
| 85 | + | |
| 86 | +afterEach(() => { | |
| 87 | + vi.unstubAllGlobals(); | |
| 88 | +}); | |
| 89 | + | |
| 90 | +it("leases only visible palette results while retaining observed and cached PR badges", async () => { | |
| 91 | + const observers: Array<{ setVisible: (visible: boolean) => void }> = []; | |
| 92 | + const scrollRoot = {}; | |
| 93 | + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 94 | + vi.stubGlobal( | |
| 95 | + "IntersectionObserver", | |
| 96 | + class { | |
| 97 | + constructor(callback: (entries: ReadonlyArray<{ isIntersecting: boolean }>) => void) { | |
| 98 | + observers.push({ setVisible: (visible) => callback([{ isIntersecting: visible }]) }); | |
| 99 | + } | |
| 100 | + observe() {} | |
| 101 | + disconnect() {} | |
| 102 | + }, | |
| 103 | + ); | |
| 104 | + const threads = Array.from({ length: 128 }, (_, index) => | |
| 105 | + makeThreadFixture({ | |
| 106 | + id: ThreadId.make(`palette-thread-${index}`), | |
| 107 | + branch: "feature/test", | |
| 108 | + worktreePath: `/test/worktree-${index}`, | |
| 109 | + linkedPullRequest: | |
| 110 | + index % 2 === 0 | |
| 111 | + ? null | |
| 112 | + : { | |
| 113 | + projectId: ProjectId.make("project-test"), | |
| 114 | + repository: "example/repo", | |
| 115 | + number: index, | |
| 116 | + url: `https://github.com/example/repo/pull/${index}`, | |
| 117 | + }, | |
| 118 | + }), | |
| 119 | + ); | |
| 120 | + let renderer: ReactTestRenderer | undefined; | |
| 121 | + state.queries.length = 0; | |
| 122 | + try { | |
| 123 | + await act(async () => { | |
| 124 | + renderer = create( | |
| 125 | + <AppAtomRegistryProvider> | |
| 126 | + {threads.map((thread, index) => ( | |
| 127 | + <span key={thread.id}> | |
| 128 | + <ThreadRowLeadingStatus | |
| 129 | + thread={thread} | |
| 130 | + snapshot={ | |
| 131 | + index === 126 | |
| 132 | + ? { | |
| 133 | + branch: "feature/test", | |
| 134 | + sourceControlProvider: undefined, | |
| 135 | + pr: { | |
| 136 | + number: 126, | |
| 137 | + title: "Cached badge", | |
| 138 | + url: "https://github.com/example/repo/pull/126", | |
| 139 | + baseRef: "main", | |
| 140 | + headRef: "feature/test", | |
| 141 | + state: "open", | |
| 142 | + }, | |
| 143 | + } | |
| 144 | + : undefined | |
| 145 | + } | |
| 146 | + /> | |
| 147 | + {thread.title} | |
| 148 | + </span> | |
| 149 | + ))} | |
| 150 | + </AppAtomRegistryProvider>, | |
| 151 | + { createNodeMock: () => ({ parentElement: { closest: () => scrollRoot } }) }, | |
| 152 | + ); | |
| 153 | + }); | |
| 154 | + const mounted = renderer!; | |
| 155 | + const badgeCount = () => | |
| 156 | + mounted.root.findAll( | |
| 157 | + (node) => node.type === "span" && String(node.props["aria-label"] ?? "").startsWith("PR #"), | |
| 158 | + ).length; | |
| 159 | + expect(observers).toHaveLength(128); | |
| 160 | + expect(state.queries).toEqual([]); | |
| 161 | + expect(badgeCount()).toBe(1); | |
| 162 | + | |
| 163 | + await act(async () => { | |
| 164 | + for (const observer of observers.slice(0, 8)) observer.setVisible(true); | |
| 165 | + }); | |
| 166 | + expect(state.queries).toHaveLength(8); | |
| 167 | + expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4); | |
| 168 | + expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4); | |
| 169 | + expect(badgeCount()).toBe(9); | |
| 170 | + | |
| 171 | + await act(async () => { | |
| 172 | + for (const observer of observers.slice(0, 8)) observer.setVisible(false); | |
| 173 | + for (const observer of observers.slice(8, 16)) observer.setVisible(true); | |
| 174 | + }); | |
| 175 | + expect(state.queries).toHaveLength(16); | |
| 176 | + expect(badgeCount()).toBe(17); | |
| 177 | + } finally { | |
| 178 | + await act(async () => renderer?.unmount()); | |
| 179 | + } | |
| 180 | +}); | |
| @@ -554,6 +554,25 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { · 553 unmodified lines | ||
| 554 | 554 | ).toEqual(mergedPr); |
| 555 | 555 | }); |
| 556 | 556 | |
| 557 | + it("shows the last matching PR snapshot while its live status lease is released", () => { | |
| 558 | + const openPr = { ...mergedPr, state: "open" as const }; | |
| 559 | + const input = { | |
| 560 | + threadBranch: featureBranch, | |
| 561 | + gitStatus: null, | |
| 562 | + snapshot: snapshotFor(featureBranch, openPr, provider), | |
| 563 | + retainTerminalOnBranchMismatch: false, | |
| 564 | + }; | |
| 565 | + expect(resolveDisplayedThreadPr(input)).toEqual(openPr); | |
| 566 | + expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); | |
| 567 | + expect(resolveDisplayedThreadPr({ ...input, threadBranch: "feature/other" })).toBeNull(); | |
| 568 | + expect( | |
| 569 | + resolveDisplayedThreadPr({ | |
| 570 | + ...input, | |
| 571 | + snapshot: { ...input.snapshot, linkedPullRequest }, | |
| 572 | + }), | |
| 573 | + ).toBeNull(); | |
| 574 | + }); | |
| 575 | + | |
| 557 | 576 | it("retains a merged PR after a main checkout", () => { |
| 558 | 577 | const matchingStatus = status({ |
| 559 | 578 | refName: featureBranch, |
| @@ -12,7 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 14 | 14 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 15 | −import { useMemo } from "react"; | |
| 15 | +import { useCallback, useMemo } from "react"; | |
| 16 | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 17 | 17 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | 18 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| @@ -27,6 +27,8 @@ import { · 8 unmodified lines | ||
| 27 | 27 | resolveThreadLastVisitedAt, |
| 28 | 28 | resolveThreadStatusPill, |
| 29 | 29 | type ThreadStatusPill, |
| 30 | + useRetainedValue, | |
| 31 | + useSidebarRowSubscriptionLease, | |
| 30 | 32 | } from "./Sidebar.logic"; |
| 31 | 33 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; |
| 32 | 34 | import type { SidebarThreadSummary } from "../types"; |
| @@ -58,9 +60,10 @@ export interface LinkedThreadPullRequestStatus { · 25 unmodified lines | ||
| 58 | 60 | export function useLinkedThreadPullRequest( |
| 59 | 61 | environmentId: EnvironmentId | null, |
| 60 | 62 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 63 | + enabled = true, | |
| 61 | 64 | ): LinkedThreadPullRequestStatus | null { |
| 62 | 65 | const queried = useEnvironmentQuery( |
| 63 | − environmentId === null || linkedPullRequest == null | |
| 66 | + !enabled || environmentId === null || linkedPullRequest == null | |
| 64 | 67 | ? null |
| 65 | 68 | : linkedPullRequestDetailAtom({ |
| 66 | 69 | environmentId, |
| @@ -353,7 +356,7 @@ export function nextThreadChangeRequestSnapshot(input: { · 286 unmodified lines | ||
| 353 | 356 | * checkouts only, a cached merged/closed PR for the thread. Local thread |
| 354 | 357 | * metadata follows the shared checkout, so the cached branch intentionally |
| 355 | 358 | * survives that metadata changing to the newly checked-out branch. Open PRs |
| 356 | − * are never retained — their state can still change. | |
| 359 | + * are retained only while live status is absent and the branch still matches. | |
| 357 | 360 | */ |
| 358 | 361 | export function resolveDisplayedThreadPr(input: { |
| 359 | 362 | threadBranch: string | null; |
| @@ -388,6 +391,15 @@ export function resolveDisplayedThreadPr(input: { · 28 unmodified lines | ||
| 388 | 391 | return gitStatus.pr; |
| 389 | 392 | } |
| 390 | 393 | |
| 394 | + if ( | |
| 395 | + gitStatus === null && | |
| 396 | + threadBranch !== null && | |
| 397 | + snapshot?.branch === threadBranch && | |
| 398 | + snapshot.linkedPullRequest === undefined | |
| 399 | + ) { | |
| 400 | + return snapshot.pr; | |
| 401 | + } | |
| 402 | + | |
| 391 | 403 | if ( |
| 392 | 404 | threadBranch !== null && |
| 393 | 405 | retainTerminalOnBranchMismatch && |
| @@ -434,6 +446,15 @@ export function resolveDisplayedThreadPrProvider(input: { · 40 unmodified lines | ||
| 434 | 446 | return gitStatus.sourceControlProvider; |
| 435 | 447 | } |
| 436 | 448 | |
| 449 | + if ( | |
| 450 | + gitStatus === null && | |
| 451 | + threadBranch !== null && | |
| 452 | + snapshot?.branch === threadBranch && | |
| 453 | + snapshot.linkedPullRequest === undefined | |
| 454 | + ) { | |
| 455 | + return snapshot.sourceControlProvider; | |
| 456 | + } | |
| 457 | + | |
| 437 | 458 | if ( |
| 438 | 459 | threadBranch !== null && |
| 439 | 460 | retainTerminalOnBranchMismatch && |
| @@ -550,7 +571,19 @@ export function ThreadStatusLabel({ · 110 unmodified lines | ||
| 550 | 571 | * like the command palette. Shows the change request state icon (if present) and the |
| 551 | 572 | * thread status dot, matching the sidebar's leading indicators. |
| 552 | 573 | */ |
| 553 | −export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { | |
| 574 | +export function ThreadRowLeadingStatus({ | |
| 575 | + thread, | |
| 576 | + snapshot, | |
| 577 | +}: { | |
| 578 | + thread: SidebarThreadSummary; | |
| 579 | + snapshot?: ThreadChangeRequestSnapshot | undefined; | |
| 580 | +}) { | |
| 581 | + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false); | |
| 582 | + // Observe the containing title even when this thread has no badge yet. | |
| 583 | + const statusRef = useCallback( | |
| 584 | + (node: HTMLSpanElement | null) => rowRef(node?.parentElement ?? null), | |
| 585 | + [rowRef], | |
| 586 | + ); | |
| 554 | 587 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 555 | 588 | const localLastVisitedAt = useUiStateStore( |
| 556 | 589 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| @@ -567,9 +600,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 10 unmodified lines | ||
| 567 | 600 | const linkedPullRequest = useLinkedThreadPullRequest( |
| 568 | 601 | thread.environmentId, |
| 569 | 602 | thread.linkedPullRequest, |
| 603 | + leaseLiveStatus, | |
| 570 | 604 | ); |
| 571 | 605 | const gitStatus = useEnvironmentQuery( |
| 572 | − thread.linkedPullRequest == null && | |
| 606 | + leaseLiveStatus && | |
| 607 | + thread.linkedPullRequest == null && | |
| 573 | 608 | (thread.branch != null || thread.worktreePath !== null) && |
| 574 | 609 | gitCwd !== null |
| 575 | 610 | ? vcsEnvironment.status({ |
| @@ -578,14 +613,20 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 2 unmodified lines | ||
| 578 | 613 | }) |
| 579 | 614 | : null, |
| 580 | 615 | ); |
| 581 | − const pr = | |
| 582 | − thread.linkedPullRequest == null | |
| 583 | − ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | |
| 584 | − : (linkedPullRequest?.pr ?? null); | |
| 585 | − const prStatus = prStatusIndicator( | |
| 586 | − pr, | |
| 587 | − linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | |
| 616 | + const visibleGitStatus = useRetainedValue( | |
| 617 | + JSON.stringify([thread.environmentId, gitCwd]), | |
| 618 | + gitStatus.data, | |
| 588 | 619 | ); |
| 620 | + const displayedPrInput = { | |
| 621 | + threadBranch: thread.branch, | |
| 622 | + gitStatus: visibleGitStatus, | |
| 623 | + snapshot, | |
| 624 | + retainTerminalOnBranchMismatch: thread.worktreePath === null, | |
| 625 | + linkedPullRequest: thread.linkedPullRequest, | |
| 626 | + linkedPullRequestStatus: linkedPullRequest, | |
| 627 | + }; | |
| 628 | + const pr = resolveDisplayedThreadPr(displayedPrInput); | |
| 629 | + const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput)); | |
| 589 | 630 | const threadStatus = resolveThreadStatusPill({ |
| 590 | 631 | thread: { |
| 591 | 632 | ...thread, |
| @@ -593,12 +634,13 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 1 unmodified line | ||
| 593 | 634 | }, |
| 594 | 635 | }); |
| 595 | 636 | |
| 596 | − if (!prStatus && !threadStatus) { | |
| 597 | − return null; | |
| 598 | − } | |
| 599 | − | |
| 600 | 637 | return ( |
| 601 | − <span className="inline-flex shrink-0 items-center gap-1.5"> | |
| 638 | + <span | |
| 639 | + ref={statusRef} | |
| 640 | + className={ | |
| 641 | + prStatus || threadStatus ? "inline-flex shrink-0 items-center gap-1.5" : "contents" | |
| 642 | + } | |
| 643 | + > | |
| 602 | 644 | {prStatus && pr ? ( |
| 603 | 645 | <Tooltip> |
| 604 | 646 | <TooltipTrigger |
| @@ -2,7 +2,11 @@ import { · 1 unmodified line | ||
| 2 | 2 | BearerConnectionTarget, |
| 3 | 3 | PrimaryConnectionTarget, |
| 4 | 4 | } from "@t3tools/client-runtime/connection"; |
| 5 | −import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | |
| 5 | +import { | |
| 6 | + type DesktopEnvironmentBootstrap, | |
| 7 | + EnvironmentId, | |
| 8 | + PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 9 | +} from "@t3tools/contracts"; | |
| 6 | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 11 | |
| 8 | 12 | import { |
| @@ -38,6 +42,63 @@ describe("desktop local connection identity", () => { · 29 unmodified lines | ||
| 38 | 42 | }); |
| 39 | 43 | |
| 40 | 44 | describe("desktop local topology reads", () => { |
| 45 | + it("reuses snapshots when polling returns fresh objects for the same topology", () => { | |
| 46 | + const secondary: DesktopEnvironmentBootstrap = { | |
| 47 | + id: "wsl:default", | |
| 48 | + label: "WSL", | |
| 49 | + runningDistro: "Ubuntu", | |
| 50 | + httpBaseUrl: "http://127.0.0.1:4000", | |
| 51 | + wsBaseUrl: "ws://127.0.0.1:4000", | |
| 52 | + bootstrapToken: "bootstrap-1", | |
| 53 | + }; | |
| 54 | + let entries = [secondary]; | |
| 55 | + const reader = createDesktopSecondaryBootstrapsReader(() => ({ | |
| 56 | + getLocalEnvironmentBootstraps: () => entries.map((entry) => ({ ...entry })), | |
| 57 | + })); | |
| 58 | + | |
| 59 | + const connected = reader.readSnapshot(); | |
| 60 | + expect(reader.readSnapshot()).toBe(connected); | |
| 61 | + expect(reader.readResult()).toEqual({ _tag: "Success", bootstraps: connected }); | |
| 62 | + expect(reader.readSnapshot()).toBe(connected); | |
| 63 | + | |
| 64 | + entries = []; | |
| 65 | + const empty = reader.readSnapshot(); | |
| 66 | + expect(empty).toEqual([]); | |
| 67 | + expect(reader.readSnapshot()).toBe(empty); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it.each<Partial<DesktopEnvironmentBootstrap>>([ | |
| 71 | + { id: "wsl:Debian" }, | |
| 72 | + { label: "Renamed backend" }, | |
| 73 | + { runningDistro: "Debian" }, | |
| 74 | + { httpBaseUrl: "http://127.0.0.1:4001" }, | |
| 75 | + { wsBaseUrl: "ws://127.0.0.1:4001" }, | |
| 76 | + { bootstrapToken: "bootstrap-2" }, | |
| 77 | + ])("publishes bootstrap changes: %j", (change) => { | |
| 78 | + let secondary: DesktopEnvironmentBootstrap = { | |
| 79 | + id: "wsl:default", | |
| 80 | + label: "WSL", | |
| 81 | + runningDistro: "Ubuntu", | |
| 82 | + httpBaseUrl: "http://127.0.0.1:4000", | |
| 83 | + wsBaseUrl: "ws://127.0.0.1:4000", | |
| 84 | + bootstrapToken: "bootstrap-1", | |
| 85 | + }; | |
| 86 | + const reader = createDesktopSecondaryBootstrapsReader(() => ({ | |
| 87 | + getLocalEnvironmentBootstraps: () => [{ ...secondary }], | |
| 88 | + })); | |
| 89 | + const before = reader.readSnapshot(); | |
| 90 | + secondary = { ...secondary, ...change }; | |
| 91 | + const after = reader.readSnapshot(); | |
| 92 | + expect(after).not.toBe(before); | |
| 93 | + expect(after).toEqual([secondary]); | |
| 94 | + expect(reader.readSnapshot()).toBe(after); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it("keeps the empty snapshot stable without a desktop bridge", () => { | |
| 98 | + const reader = createDesktopSecondaryBootstrapsReader(() => undefined); | |
| 99 | + expect(reader.readSnapshot()).toBe(reader.readSnapshot()); | |
| 100 | + }); | |
| 101 | + | |
| 41 | 102 | it("distinguishes a successful empty topology from a read failure", () => { |
| 42 | 103 | let readBootstraps = () => []; |
| 43 | 104 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ |
| @@ -67,13 +67,30 @@ export function createDesktopSecondaryBootstrapsReader( · 66 unmodified lines | ||
| 67 | 67 | const readResult = (): DesktopSecondaryBootstrapsRead => { |
| 68 | 68 | const bridge = resolveBridge(); |
| 69 | 69 | if (bridge === undefined) { |
| 70 | − snapshot = []; | |
| 70 | + if (snapshot.length > 0) snapshot = []; | |
| 71 | 71 | return { _tag: "Success", bootstraps: snapshot }; |
| 72 | 72 | } |
| 73 | 73 | try { |
| 74 | − snapshot = bridge | |
| 74 | + const next = bridge | |
| 75 | 75 | .getLocalEnvironmentBootstraps() |
| 76 | 76 | .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); |
| 77 | + if ( | |
| 78 | + next.length !== snapshot.length || | |
| 79 | + next.some((entry, index) => { | |
| 80 | + const previous = snapshot[index]; | |
| 81 | + return ( | |
| 82 | + previous === undefined || | |
| 83 | + entry.id !== previous.id || | |
| 84 | + entry.label !== previous.label || | |
| 85 | + entry.runningDistro !== previous.runningDistro || | |
| 86 | + entry.httpBaseUrl !== previous.httpBaseUrl || | |
| 87 | + entry.wsBaseUrl !== previous.wsBaseUrl || | |
| 88 | + entry.bootstrapToken !== previous.bootstrapToken | |
| 89 | + ); | |
| 90 | + }) | |
| 91 | + ) { | |
| 92 | + snapshot = next; | |
| 93 | + } | |
| 77 | 94 | return { _tag: "Success", bootstraps: snapshot }; |
| 78 | 95 | } catch (cause) { |
| 79 | 96 | return { _tag: "Failure", cause }; |