Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
import { environmentProjectCloneListAtom } from "./projectClones";
import { environmentSession, usePreparedConnection } from "./session";
import { useAtomQueryRunner } from "./use-atom-query-runner";

Expand All @@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
imageCache: projectFaviconDatabaseCache,
createUrl: assetEnvironment.createUrl,
preparedConnection: environmentSession.preparedConnectionValueAtom,
projectClones: environmentProjectCloneListAtom,
});

const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/state/projectClones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId)
}).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)),
);

/** The environment's tracked clones, empty until the stream's first list. */
export const environmentProjectCloneListAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => {
const clones = get(environmentProjectClonesAtom(environmentId));
return clones === "pending" ? EMPTY_CLONES : clones;
}).pipe(Atom.withLabel(`mobile-project-clone-list:${environmentId}`)),
);

const projectCloneAtom = Atom.family((key: string) => {
const ref = parseScopedProjectKey(key);
return Atom.make((get): ProjectCloneState => {
Expand Down
27 changes: 16 additions & 11 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
readonly resource: AssetResource;
readonly workspaceRoot?: string;
readonly projectFaviconPath?: string;
/** The project's clone has not landed, so its icon is reported missing without a lookup. */
readonly projectCheckoutPending?: boolean;
}) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
),
);
const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const faviconPath = yield* faviconResolver
.resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
.pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconResolutionError({
resource: input.resource,
cause,
}),
),
);
// A lookup in a half-cloned checkout would cache a miss that outlives the clone.
const faviconPath = input.projectCheckoutPending
Comment thread
t3dotgg marked this conversation as resolved.
? null
: yield* faviconResolver
.resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
.pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconResolutionError({
resource: input.resource,
cause,
}),
),
);
const isExternalOverride =
faviconPath !== null &&
input.projectFaviconPath !== undefined &&
Expand Down
81 changes: 81 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("finds a cloned project's icon once the clone lands", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-clone-favicon-" });
const destinationPath = path.join(parentDir, "app");
const projectId = ProjectId.make("project-clone-favicon");
const cloneGate = yield* Deferred.make<void>();
const metaUpdateDispatched = yield* Deferred.make<void>();

yield* buildAppUnderTest({
layers: {
orchestrationEngine: {
dispatch: (command) =>
(command.type === "project.meta.update"
? Deferred.succeed(metaUpdateDispatched, undefined)
: Effect.void
).pipe(Effect.as({ sequence: 1 })),
},
projectionSnapshotQuery: {
getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
Effect.succeed(
workspaceRoot === destinationPath
? Option.some({
...makeDefaultOrchestrationReadModel().projects[0]!,
id: projectId,
workspaceRoot,
})
: Option.none(),
),
},
sourceControlRepositoryService: {
prepareClone: (input) =>
Effect.succeed({
destinationPath: input.destinationPath,
remoteUrl: input.remoteUrl ?? "",
cloneUrl: input.remoteUrl ?? "",
repository: null,
}),
cloneRepository: (input) =>
Deferred.await(cloneGate).pipe(
Effect.andThen(
fs.writeFileString(path.join(input.destinationPath, "favicon.svg"), "<svg/>"),
),
Effect.orDie,
Effect.as({
cwd: input.destinationPath,
remoteUrl: input.remoteUrl ?? "",
repository: null,
}),
),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.gen(function* () {
yield* client[WS_METHODS.projectCloneStart]({
projectId,
title: "app",
createdAt: "2026-01-01T00:00:00.000Z",
remoteUrl: "git@github.com:octocat/app.git",
destinationPath,
});
const resource = { _tag: "project-favicon" as const, cwd: destinationPath };
const duringClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource });
assert.isTrue(duringClone.relativeUrl.endsWith("/project-favicon-missing"));

yield* Deferred.succeed(cloneGate, undefined);
yield* Deferred.await(metaUpdateDispatched);
// The lookup during the clone must not leave a cached miss behind.
const afterClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource });
assert.equal(afterClone.sourcePath, "favicon.svg");
}),
),
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("records thread analytics only after a client command succeeds", () =>
Effect.gen(function* () {
const effects: string[] = [];
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3508,11 +3508,18 @@ const makeWsRpcLayer = (
resource: input.resource,
});
}
// A cloned project exists before its files do. Clients ask again
// when the clone lands (see createProjectFaviconUrlAtomFamily).
const clone = yield* projectCloneTracker.get(project.value.id);
return yield* issueAssetUrl({
resource: input.resource,
...(project.value.faviconPath
? { projectFaviconPath: project.value.faviconPath }
: {}),
projectCheckoutPending:
clone !== null &&
clone.phase !== "done" &&
clone.destinationPath === project.value.workspaceRoot,
});
}
const thread = yield* projectionSnapshotQuery
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime";
import { projectFaviconCache } from "../assets/projectFaviconCache";
import { isElectron } from "../env";
import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
import { environmentProjectClonesAtom } from "./projectClones";
import { environmentSession } from "./session";

const localMediaEnvironment = Atom.make((get) => {
Expand All @@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
imageCache: projectFaviconCache,
createUrl: assetEnvironment.createUrl,
preparedConnection: environmentSession.preparedConnectionValueAtom,
projectClones: environmentProjectClonesAtom,
});
2 changes: 1 addition & 1 deletion apps/web/src/state/projectClones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe(
* Latest clone list an environment has streamed; empty until the subscription
* delivers, and never subscribed on servers that predate clone tracking.
*/
const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
export const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => {
const supported =
get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
Expand Down
92 changes: 92 additions & 0 deletions packages/client-runtime/src/state/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
AssetWorkspaceContextNotFoundError,
EnvironmentAuthorizationError,
EnvironmentId,
type ProjectCloneSnapshot,
ProjectId,
ThreadId,
WS_METHODS,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -409,4 +411,94 @@ describe("project favicon URL cache", () => {
registry.dispose();
}
});

const cloning: ProjectCloneSnapshot = {
projectId: ProjectId.make("project-cloning"),
remoteUrl: "git@github.com:octocat/app.git",
destinationPath: "/workspace",
repository: null,
phase: "running",
stage: "receiving",
percent: 10,
detail: null,
error: null,
startedAt: "2026-01-01T00:00:00.000Z",
endedAt: null,
sequence: 1,
};

function mountClonedProjectFavicon(initialClones: ReadonlyArray<ProjectCloneSnapshot>) {
const registry = AtomRegistry.make();
// Stands in for the server, which reports the icon missing until the clone lands.
const server = { lookups: 0, landed: false };
const result = Atom.make(() => {
server.lookups += 1;
return AsyncResult.success({
expiresAt: 4_000_000_000_000,
relativeUrl: server.landed
? "/api/assets/token-b/v1-icon.svg"
: "/api/assets/token-a/project-favicon-missing",
});
});
const clones = Atom.make(initialClones);
const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" }));
const favicon = createProjectFaviconUrlAtomFamily({
createUrl: () => result,
preparedConnection: () => connection,
projectClones: () => clones,
})({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" });
const unmount = registry.mount(favicon);
return {
registry,
server,
clones,
favicon,
dispose: () => {
unmount();
registry.dispose();
},
};
}

it("asks for a cloned project's icon again once its clone lands", () => {
const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([cloning]);
try {
expect(registry.get(favicon)).toBe(
"https://remote.test/api/assets/token-a/project-favicon-missing",
);
// Progress, and another folder's clone landing, do not ask again.
registry.set(clones, [
{ ...cloning, percent: 80, sequence: 2 },
{
...cloning,
projectId: ProjectId.make("project-other"),
destinationPath: "/other",
phase: "done",
sequence: 3,
},
]);
expect(server.lookups).toBe(1);

server.landed = true;
registry.set(clones, [{ ...cloning, phase: "done", sequence: 4 }]);
expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg");
expect(server.lookups).toBe(2);
} finally {
dispose();
}
});

it("asks again when the first clone list it sees already says done", () => {
const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([]);
try {
expect(registry.get(favicon)).toBe(
"https://remote.test/api/assets/token-a/project-favicon-missing",
);
server.landed = true;
registry.set(clones, [{ ...cloning, phase: "done", sequence: 2 }]);
expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg");
} finally {
dispose();
}
});
});
22 changes: 21 additions & 1 deletion packages/client-runtime/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type AssetImageDimensions,
AssetResource,
EnvironmentId,
type ProjectCloneSnapshot,
WS_METHODS,
} from "@t3tools/contracts";
import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
Expand Down Expand Up @@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: {
readonly preparedConnection: (
environmentId: EnvironmentId,
) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>;
/** The environment's tracked clones, empty when it reports none. */
readonly projectClones?: (
environmentId: EnvironmentId,
) => Atom.Atom<ReadonlyArray<ProjectCloneSnapshot>>;
}) {
const decodeKey = Schema.decodeUnknownSync(
Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),
);
const projectClones = input.projectClones;
const family = Atom.family((key: string) => {
const [environmentId, cwd, path] = decodeKey(JSON.parse(key));
const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };
const request = input.createUrl({ environmentId, input: { resource } });
const query = input.createUrl({ environmentId, input: { resource } });
// A cloned project exists before its files do, and the server reports its
// icon missing until the clone lands. Ask again whenever the clone's phase
// changes: the first list a client sees may already say done.
const request = projectClones
? query.pipe(
Atom.makeRefreshOnSignal(
Atom.make(
(get) =>
get(projectClones(environmentId)).find((clone) => clone.destinationPath === cwd)
?.phase ?? null,
),
),
)
: query;
const resolvedUrl = Atom.make((get): string | null => {
const result = get(request);
const connection = get(input.preparedConnection(environmentId));
Expand Down
Loading