| author | |
| committer | |
| log | 6ea01f8d24419019588f2be589681929d8e26b0c |
| tree | 5dbf96b592533f8483a036b35176f3446bc621b6 |
| parent | a97a4a9d189f145afe79c3a6f8533bbf9a6b40b1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>9 files changed, 230 insertions(+), 13 deletions(-)
apps/mobile/src/state/assets.ts+2| ... | @@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog"; | ... | @@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog"; |
| 18 | import { connectionAtomRuntime } from "../connection/runtime"; | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; | 19 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; |
| 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; | 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 21 | import { environmentProjectCloneListAtom } from "./projectClones"; | ||
| 21 | import { environmentSession, usePreparedConnection } from "./session"; | 22 | import { environmentSession, usePreparedConnection } from "./session"; |
| 22 | import { useAtomQueryRunner } from "./use-atom-query-runner"; | 23 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 23 | 24 | ||
| ... | @@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | ... | @@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ |
| 29 | imageCache: projectFaviconDatabaseCache, | 30 | imageCache: projectFaviconDatabaseCache, |
| 30 | createUrl: assetEnvironment.createUrl, | 31 | createUrl: assetEnvironment.createUrl, |
| 31 | preparedConnection: environmentSession.preparedConnectionValueAtom, | 32 | preparedConnection: environmentSession.preparedConnectionValueAtom, |
| 33 | projectClones: environmentProjectCloneListAtom, | ||
| 32 | }); | 34 | }); |
| 33 | 35 | ||
| 34 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | 36 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( |
apps/mobile/src/state/projectClones.ts+8| ... | @@ -36,6 +36,14 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) | ... | @@ -36,6 +36,14 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) |
| 36 | }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), | 36 | }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), |
| 37 | ); | 37 | ); |
| 38 | 38 | ||
| 39 | /** The environment's tracked clones, empty until the stream's first list. */ | ||
| 40 | export const environmentProjectCloneListAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 41 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => { | ||
| 42 | const clones = get(environmentProjectClonesAtom(environmentId)); | ||
| 43 | return clones === "pending" ? EMPTY_CLONES : clones; | ||
| 44 | }).pipe(Atom.withLabel(`mobile-project-clone-list:${environmentId}`)), | ||
| 45 | ); | ||
| 46 | |||
| 39 | const projectCloneAtom = Atom.family((key: string) => { | 47 | const projectCloneAtom = Atom.family((key: string) => { |
| 40 | const ref = parseScopedProjectKey(key); | 48 | const ref = parseScopedProjectKey(key); |
| 41 | return Atom.make((get): ProjectCloneState => { | 49 | return Atom.make((get): ProjectCloneState => { |
apps/server/src/assets/AssetAccess.ts+16-11| ... | @@ -419,6 +419,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -419,6 +419,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 419 | readonly resource: AssetResource; | 419 | readonly resource: AssetResource; |
| 420 | readonly workspaceRoot?: string; | 420 | readonly workspaceRoot?: string; |
| 421 | readonly projectFaviconPath?: string; | 421 | readonly projectFaviconPath?: string; |
| 422 | /** The project's clone has not landed, so its icon is reported missing without a lookup. */ | ||
| 423 | readonly projectCheckoutPending?: boolean; | ||
| 422 | }) { | 424 | }) { |
| 423 | const fileSystem = yield* FileSystem.FileSystem; | 425 | const fileSystem = yield* FileSystem.FileSystem; |
| 424 | const path = yield* Path.Path; | 426 | const path = yield* Path.Path; |
| ... | @@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 573 | ), | 575 | ), |
| 574 | ); | 576 | ); |
| 575 | const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; | 577 | const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; |
| 576 | const faviconPath = yield* faviconResolver | 578 | // A lookup in a half-cloned checkout would cache a miss that outlives the clone. |
| 577 | .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) | 579 | const faviconPath = input.projectCheckoutPending |
| 578 | .pipe( | 580 | ? null |
| 579 | Effect.mapError( | 581 | : yield* faviconResolver |
| 580 | (cause) => | 582 | .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) |
| 581 | new AssetProjectFaviconResolutionError({ | 583 | .pipe( |
| 582 | resource: input.resource, | 584 | Effect.mapError( |
| 583 | cause, | 585 | (cause) => |
| 584 | }), | 586 | new AssetProjectFaviconResolutionError({ |
| 585 | ), | 587 | resource: input.resource, |
| 586 | ); | 588 | cause, |
| 589 | }), | ||
| 590 | ), | ||
| 591 | ); | ||
| 587 | const isExternalOverride = | 592 | const isExternalOverride = |
| 588 | faviconPath !== null && | 593 | faviconPath !== null && |
| 589 | input.projectFaviconPath !== undefined && | 594 | input.projectFaviconPath !== undefined && |
apps/server/src/server.test.ts+81| ... | @@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 8153 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 8153 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 8154 | ); | 8154 | ); |
| 8155 | 8155 | ||
| 8156 | it.effect("finds a cloned project's icon once the clone lands", () => | ||
| 8157 | Effect.gen(function* () { | ||
| 8158 | const fs = yield* FileSystem.FileSystem; | ||
| 8159 | const path = yield* Path.Path; | ||
| 8160 | const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-clone-favicon-" }); | ||
| 8161 | const destinationPath = path.join(parentDir, "app"); | ||
| 8162 | const projectId = ProjectId.make("project-clone-favicon"); | ||
| 8163 | const cloneGate = yield* Deferred.make<void>(); | ||
| 8164 | const metaUpdateDispatched = yield* Deferred.make<void>(); | ||
| 8165 | |||
| 8166 | yield* buildAppUnderTest({ | ||
| 8167 | layers: { | ||
| 8168 | orchestrationEngine: { | ||
| 8169 | dispatch: (command) => | ||
| 8170 | (command.type === "project.meta.update" | ||
| 8171 | ? Deferred.succeed(metaUpdateDispatched, undefined) | ||
| 8172 | : Effect.void | ||
| 8173 | ).pipe(Effect.as({ sequence: 1 })), | ||
| 8174 | }, | ||
| 8175 | projectionSnapshotQuery: { | ||
| 8176 | getActiveProjectByWorkspaceRoot: (workspaceRoot) => | ||
| 8177 | Effect.succeed( | ||
| 8178 | workspaceRoot === destinationPath | ||
| 8179 | ? Option.some({ | ||
| 8180 | ...makeDefaultOrchestrationReadModel().projects[0]!, | ||
| 8181 | id: projectId, | ||
| 8182 | workspaceRoot, | ||
| 8183 | }) | ||
| 8184 | : Option.none(), | ||
| 8185 | ), | ||
| 8186 | }, | ||
| 8187 | sourceControlRepositoryService: { | ||
| 8188 | prepareClone: (input) => | ||
| 8189 | Effect.succeed({ | ||
| 8190 | destinationPath: input.destinationPath, | ||
| 8191 | remoteUrl: input.remoteUrl ?? "", | ||
| 8192 | cloneUrl: input.remoteUrl ?? "", | ||
| 8193 | repository: null, | ||
| 8194 | }), | ||
| 8195 | cloneRepository: (input) => | ||
| 8196 | Deferred.await(cloneGate).pipe( | ||
| 8197 | Effect.andThen( | ||
| 8198 | fs.writeFileString(path.join(input.destinationPath, "favicon.svg"), "<svg/>"), | ||
| 8199 | ), | ||
| 8200 | Effect.orDie, | ||
| 8201 | Effect.as({ | ||
| 8202 | cwd: input.destinationPath, | ||
| 8203 | remoteUrl: input.remoteUrl ?? "", | ||
| 8204 | repository: null, | ||
| 8205 | }), | ||
| 8206 | ), | ||
| 8207 | }, | ||
| 8208 | }, | ||
| 8209 | }); | ||
| 8210 | |||
| 8211 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 8212 | yield* Effect.scoped( | ||
| 8213 | withWsRpcClient(wsUrl, (client) => | ||
| 8214 | Effect.gen(function* () { | ||
| 8215 | yield* client[WS_METHODS.projectCloneStart]({ | ||
| 8216 | projectId, | ||
| 8217 | title: "app", | ||
| 8218 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 8219 | remoteUrl: "git@github.com:octocat/app.git", | ||
| 8220 | destinationPath, | ||
| 8221 | }); | ||
| 8222 | const resource = { _tag: "project-favicon" as const, cwd: destinationPath }; | ||
| 8223 | const duringClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource }); | ||
| 8224 | assert.isTrue(duringClone.relativeUrl.endsWith("/project-favicon-missing")); | ||
| 8225 | |||
| 8226 | yield* Deferred.succeed(cloneGate, undefined); | ||
| 8227 | yield* Deferred.await(metaUpdateDispatched); | ||
| 8228 | // The lookup during the clone must not leave a cached miss behind. | ||
| 8229 | const afterClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource }); | ||
| 8230 | assert.equal(afterClone.sourcePath, "favicon.svg"); | ||
| 8231 | }), | ||
| 8232 | ), | ||
| 8233 | ); | ||
| 8234 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 8235 | ); | ||
| 8236 | |||
| 8156 | it.effect("records thread analytics only after a client command succeeds", () => | 8237 | it.effect("records thread analytics only after a client command succeeds", () => |
| 8157 | Effect.gen(function* () { | 8238 | Effect.gen(function* () { |
| 8158 | const effects: string[] = []; | 8239 | const effects: string[] = []; |
apps/server/src/ws.ts+7| ... | @@ -3508,11 +3508,18 @@ const makeWsRpcLayer = ( | ... | @@ -3508,11 +3508,18 @@ const makeWsRpcLayer = ( |
| 3508 | resource: input.resource, | 3508 | resource: input.resource, |
| 3509 | }); | 3509 | }); |
| 3510 | } | 3510 | } |
| 3511 | // A cloned project exists before its files do. Clients ask again | ||
| 3512 | // when the clone lands (see createProjectFaviconUrlAtomFamily). | ||
| 3513 | const clone = yield* projectCloneTracker.get(project.value.id); | ||
| 3511 | return yield* issueAssetUrl({ | 3514 | return yield* issueAssetUrl({ |
| 3512 | resource: input.resource, | 3515 | resource: input.resource, |
| 3513 | ...(project.value.faviconPath | 3516 | ...(project.value.faviconPath |
| 3514 | ? { projectFaviconPath: project.value.faviconPath } | 3517 | ? { projectFaviconPath: project.value.faviconPath } |
| 3515 | : {}), | 3518 | : {}), |
| 3519 | projectCheckoutPending: | ||
| 3520 | clone !== null && | ||
| 3521 | clone.phase !== "done" && | ||
| 3522 | clone.destinationPath === project.value.workspaceRoot, | ||
| 3516 | }); | 3523 | }); |
| 3517 | } | 3524 | } |
| 3518 | const thread = yield* projectionSnapshotQuery | 3525 | const thread = yield* projectionSnapshotQuery |
apps/web/src/state/assets.ts+2| ... | @@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime"; | ... | @@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | import { projectFaviconCache } from "../assets/projectFaviconCache"; | 8 | import { projectFaviconCache } from "../assets/projectFaviconCache"; |
| 9 | import { isElectron } from "../env"; | 9 | import { isElectron } from "../env"; |
| 10 | import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; | 10 | import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; |
| 11 | import { environmentProjectClonesAtom } from "./projectClones"; | ||
| 11 | import { environmentSession } from "./session"; | 12 | import { environmentSession } from "./session"; |
| 12 | 13 | ||
| 13 | const localMediaEnvironment = Atom.make((get) => { | 14 | const localMediaEnvironment = Atom.make((get) => { |
| ... | @@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | ... | @@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ |
| 31 | imageCache: projectFaviconCache, | 32 | imageCache: projectFaviconCache, |
| 32 | createUrl: assetEnvironment.createUrl, | 33 | createUrl: assetEnvironment.createUrl, |
| 33 | preparedConnection: environmentSession.preparedConnectionValueAtom, | 34 | preparedConnection: environmentSession.preparedConnectionValueAtom, |
| 35 | projectClones: environmentProjectClonesAtom, | ||
| 34 | }); | 36 | }); |
apps/web/src/state/projectClones.ts+1-1| ... | @@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe( | ... | @@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe( |
| 16 | * Latest clone list an environment has streamed; empty until the subscription | 16 | * Latest clone list an environment has streamed; empty until the subscription |
| 17 | * delivers, and never subscribed on servers that predate clone tracking. | 17 | * delivers, and never subscribed on servers that predate clone tracking. |
| 18 | */ | 18 | */ |
| 19 | const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => | 19 | export const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => |
| 20 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => { | 20 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => { |
| 21 | const supported = | 21 | const supported = |
| 22 | get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 22 | get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities |
packages/client-runtime/src/state/assets.test.ts+92| ... | @@ -6,6 +6,8 @@ import { | ... | @@ -6,6 +6,8 @@ import { |
| 6 | AssetWorkspaceContextNotFoundError, | 6 | AssetWorkspaceContextNotFoundError, |
| 7 | EnvironmentAuthorizationError, | 7 | EnvironmentAuthorizationError, |
| 8 | EnvironmentId, | 8 | EnvironmentId, |
| 9 | type ProjectCloneSnapshot, | ||
| 10 | ProjectId, | ||
| 9 | ThreadId, | 11 | ThreadId, |
| 10 | WS_METHODS, | 12 | WS_METHODS, |
| 11 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| ... | @@ -409,4 +411,94 @@ describe("project favicon URL cache", () => { | ... | @@ -409,4 +411,94 @@ describe("project favicon URL cache", () => { |
| 409 | registry.dispose(); | 411 | registry.dispose(); |
| 410 | } | 412 | } |
| 411 | }); | 413 | }); |
| 414 | |||
| 415 | const cloning: ProjectCloneSnapshot = { | ||
| 416 | projectId: ProjectId.make("project-cloning"), | ||
| 417 | remoteUrl: "git@github.com:octocat/app.git", | ||
| 418 | destinationPath: "/workspace", | ||
| 419 | repository: null, | ||
| 420 | phase: "running", | ||
| 421 | stage: "receiving", | ||
| 422 | percent: 10, | ||
| 423 | detail: null, | ||
| 424 | error: null, | ||
| 425 | startedAt: "2026-01-01T00:00:00.000Z", | ||
| 426 | endedAt: null, | ||
| 427 | sequence: 1, | ||
| 428 | }; | ||
| 429 | |||
| 430 | function mountClonedProjectFavicon(initialClones: ReadonlyArray<ProjectCloneSnapshot>) { | ||
| 431 | const registry = AtomRegistry.make(); | ||
| 432 | // Stands in for the server, which reports the icon missing until the clone lands. | ||
| 433 | const server = { lookups: 0, landed: false }; | ||
| 434 | const result = Atom.make(() => { | ||
| 435 | server.lookups += 1; | ||
| 436 | return AsyncResult.success({ | ||
| 437 | expiresAt: 4_000_000_000_000, | ||
| 438 | relativeUrl: server.landed | ||
| 439 | ? "/api/assets/token-b/v1-icon.svg" | ||
| 440 | : "/api/assets/token-a/project-favicon-missing", | ||
| 441 | }); | ||
| 442 | }); | ||
| 443 | const clones = Atom.make(initialClones); | ||
| 444 | const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); | ||
| 445 | const favicon = createProjectFaviconUrlAtomFamily({ | ||
| 446 | createUrl: () => result, | ||
| 447 | preparedConnection: () => connection, | ||
| 448 | projectClones: () => clones, | ||
| 449 | })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); | ||
| 450 | const unmount = registry.mount(favicon); | ||
| 451 | return { | ||
| 452 | registry, | ||
| 453 | server, | ||
| 454 | clones, | ||
| 455 | favicon, | ||
| 456 | dispose: () => { | ||
| 457 | unmount(); | ||
| 458 | registry.dispose(); | ||
| 459 | }, | ||
| 460 | }; | ||
| 461 | } | ||
| 462 | |||
| 463 | it("asks for a cloned project's icon again once its clone lands", () => { | ||
| 464 | const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([cloning]); | ||
| 465 | try { | ||
| 466 | expect(registry.get(favicon)).toBe( | ||
| 467 | "https://remote.test/api/assets/token-a/project-favicon-missing", | ||
| 468 | ); | ||
| 469 | // Progress, and another folder's clone landing, do not ask again. | ||
| 470 | registry.set(clones, [ | ||
| 471 | { ...cloning, percent: 80, sequence: 2 }, | ||
| 472 | { | ||
| 473 | ...cloning, | ||
| 474 | projectId: ProjectId.make("project-other"), | ||
| 475 | destinationPath: "/other", | ||
| 476 | phase: "done", | ||
| 477 | sequence: 3, | ||
| 478 | }, | ||
| 479 | ]); | ||
| 480 | expect(server.lookups).toBe(1); | ||
| 481 | |||
| 482 | server.landed = true; | ||
| 483 | registry.set(clones, [{ ...cloning, phase: "done", sequence: 4 }]); | ||
| 484 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg"); | ||
| 485 | expect(server.lookups).toBe(2); | ||
| 486 | } finally { | ||
| 487 | dispose(); | ||
| 488 | } | ||
| 489 | }); | ||
| 490 | |||
| 491 | it("asks again when the first clone list it sees already says done", () => { | ||
| 492 | const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([]); | ||
| 493 | try { | ||
| 494 | expect(registry.get(favicon)).toBe( | ||
| 495 | "https://remote.test/api/assets/token-a/project-favicon-missing", | ||
| 496 | ); | ||
| 497 | server.landed = true; | ||
| 498 | registry.set(clones, [{ ...cloning, phase: "done", sequence: 2 }]); | ||
| 499 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg"); | ||
| 500 | } finally { | ||
| 501 | dispose(); | ||
| 502 | } | ||
| 503 | }); | ||
| 412 | }); | 504 | }); |
packages/client-runtime/src/state/assets.ts+21-1| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | type AssetImageDimensions, | 4 | type AssetImageDimensions, |
| 5 | AssetResource, | 5 | AssetResource, |
| 6 | EnvironmentId, | 6 | EnvironmentId, |
| 7 | type ProjectCloneSnapshot, | ||
| 7 | WS_METHODS, | 8 | WS_METHODS, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | 10 | import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| ... | @@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: { | ... | @@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: { |
| 181 | readonly preparedConnection: ( | 182 | readonly preparedConnection: ( |
| 182 | environmentId: EnvironmentId, | 183 | environmentId: EnvironmentId, |
| 183 | ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>; | 184 | ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>; |
| 185 | /** The environment's tracked clones, empty when it reports none. */ | ||
| 186 | readonly projectClones?: ( | ||
| 187 | environmentId: EnvironmentId, | ||
| 188 | ) => Atom.Atom<ReadonlyArray<ProjectCloneSnapshot>>; | ||
| 184 | }) { | 189 | }) { |
| 185 | const decodeKey = Schema.decodeUnknownSync( | 190 | const decodeKey = Schema.decodeUnknownSync( |
| 186 | Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), | 191 | Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), |
| 187 | ); | 192 | ); |
| 193 | const projectClones = input.projectClones; | ||
| 188 | const family = Atom.family((key: string) => { | 194 | const family = Atom.family((key: string) => { |
| 189 | const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); | 195 | const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); |
| 190 | const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; | 196 | const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; |
| 191 | const request = input.createUrl({ environmentId, input: { resource } }); | 197 | const query = input.createUrl({ environmentId, input: { resource } }); |
| 198 | // A cloned project exists before its files do, and the server reports its | ||
| 199 | // icon missing until the clone lands. Ask again whenever the clone's phase | ||
| 200 | // changes: the first list a client sees may already say done. | ||
| 201 | const request = projectClones | ||
| 202 | ? query.pipe( | ||
| 203 | Atom.makeRefreshOnSignal( | ||
| 204 | Atom.make( | ||
| 205 | (get) => | ||
| 206 | get(projectClones(environmentId)).find((clone) => clone.destinationPath === cwd) | ||
| 207 | ?.phase ?? null, | ||
| 208 | ), | ||
| 209 | ), | ||
| 210 | ) | ||
| 211 | : query; | ||
| 192 | const resolvedUrl = Atom.make((get): string | null => { | 212 | const resolvedUrl = Atom.make((get): string | null => { |
| 193 | const result = get(request); | 213 | const result = get(request); |
| 194 | const connection = get(input.preparedConnection(environmentId)); | 214 | const connection = get(input.preparedConnection(environmentId)); |