Skip to content

Commit 7a5e7a2

Browse files
fix(web): choosing a project icon again re-reads it from disk
Picking an icon path the project had before reused that path's cached icon URL. If the file was missing when that URL was minted (for example before the checkout was cloned), the project kept showing its monogram for up to 30 minutes. A successful pick now refreshes the icon URL query. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent b50d8a1 commit 7a5e7a2

4 files changed

Lines changed: 84 additions & 5 deletions

File tree

‎apps/web/src/components/settings/ProjectSettingsPanel.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "../../sidebarProjectGrouping";
2626
import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
2727
import { useThreadShells, waitForProjectRemoval } from "../../state/entities";
28+
import { refreshProjectFaviconUrl } from "../../state/assets";
2829
import { projectEnvironment } from "../../state/projects";
2930
import { ProjectFavicon } from "../ProjectFavicon";
3031
import { Alert, AlertDescription } from "../ui/alert";
@@ -257,7 +258,18 @@ function ProjectDetail({
257258
if (savingFaviconRef.current) return;
258259
savingFaviconRef.current = true;
259260
setIsSavingFavicon(true);
260-
await updateAllMembers(input, "Failed to update project icon");
261+
const result = await updateAllMembers(input, "Failed to update project icon");
262+
if (result._tag === "Success") {
263+
for (const member of group.memberProjects) {
264+
const update = typeof input === "function" ? input(member) : input;
265+
if (update.faviconPath === undefined || update.projectIcon) continue;
266+
refreshProjectFaviconUrl({
267+
environmentId: member.environmentId,
268+
cwd: member.workspaceRoot,
269+
faviconPath: update.faviconPath,
270+
});
271+
}
272+
}
261273
savingFaviconRef.current = false;
262274
setIsSavingFavicon(false);
263275
};

‎apps/web/src/state/assets.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import type { ProjectFaviconTarget } from "@t3tools/client-runtime/project-favicon-cache";
12
import {
23
createAssetEnvironmentAtoms,
34
createProjectFaviconUrlAtomFamily,
5+
projectFaviconResource,
46
} from "@t3tools/client-runtime/state/assets";
57
import { Atom } from "effect/reactivity";
68

79
import { connectionAtomRuntime } from "../connection/runtime";
10+
import { appAtomRegistry } from "../rpc/atomRegistry";
811
import { projectFaviconCache } from "../assets/projectFaviconCache";
912
import { isElectron } from "../env";
1013
import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
@@ -33,3 +36,16 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
3336
preparedConnection: environmentSession.preparedConnectionValueAtom,
3437
projectClones: environmentProjectClonesAtom,
3538
});
39+
40+
/**
41+
* Asks the server for a project's icon again. Choosing the icon path a project had before would
42+
* otherwise reuse that path's cached URL, which says "missing" if the file did not exist then.
43+
*/
44+
export function refreshProjectFaviconUrl(target: ProjectFaviconTarget) {
45+
appAtomRegistry.refresh(
46+
assetEnvironment.createUrl({
47+
environmentId: target.environmentId,
48+
input: { resource: projectFaviconResource(target.cwd, target.faviconPath) },
49+
}),
50+
);
51+
}

‎packages/client-runtime/src/state/assets.test.ts‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { describe, expect, it } from "@effect/vitest";
2-
import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
2+
import { type AssetCreateUrlResult, type AssetResource, EnvironmentId } from "@t3tools/contracts";
33
import * as Cause from "effect/Cause";
44
import * as Option from "effect/Option";
55
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
66

7-
import { createProjectFaviconUrlAtomFamily } from "./assets.ts";
7+
import { createProjectFaviconUrlAtomFamily, projectFaviconResource } from "./assets.ts";
88

99
describe("project favicon URL cache", () => {
1010
it("does not reuse another environment, workspace, or selected icon's cached URL", () => {
@@ -37,4 +37,45 @@ describe("project favicon URL cache", () => {
3737
registry.dispose();
3838
}
3939
});
40+
41+
it("resolves the icon again when its resource's URL query is refreshed", () => {
42+
const registry = AtomRegistry.make();
43+
const answers = ["/api/assets/token/project-favicon-missing", "/api/assets/token/v1-icon.svg"];
44+
const query = Atom.family((key: string) =>
45+
Atom.make(() =>
46+
AsyncResult.success({ expiresAt: 4_000_000_000_000, relativeUrl: answers.shift()! }),
47+
).pipe(Atom.withLabel(key)),
48+
);
49+
const createUrl = (target: {
50+
readonly environmentId: EnvironmentId;
51+
readonly input: { readonly resource: AssetResource };
52+
}) => query(JSON.stringify([target.environmentId, target.input.resource]));
53+
const favicon = createProjectFaviconUrlAtomFamily({
54+
createUrl,
55+
preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })),
56+
});
57+
const target = {
58+
environmentId: EnvironmentId.make("remote"),
59+
cwd: "/workspace",
60+
faviconPath: "icon.svg",
61+
};
62+
const unmount = registry.mount(favicon(target));
63+
try {
64+
expect(registry.get(favicon(target))).toBe(
65+
"https://remote.test/api/assets/token/project-favicon-missing",
66+
);
67+
registry.refresh(
68+
createUrl({
69+
environmentId: target.environmentId,
70+
input: { resource: projectFaviconResource(target.cwd, target.faviconPath) },
71+
}),
72+
);
73+
expect(registry.get(favicon(target))).toBe(
74+
"https://remote.test/api/assets/token/v1-icon.svg",
75+
);
76+
} finally {
77+
unmount();
78+
registry.dispose();
79+
}
80+
});
4081
});

‎packages/client-runtime/src/state/assets.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ export function createAssetEnvironmentAtoms<R, E>(
200200
};
201201
}
202202

203+
/**
204+
* The resource a project's icon URL is minted for. Refreshing the `createUrl` query for it makes
205+
* the server resolve the icon again instead of serving a cached answer.
206+
*/
207+
export function projectFaviconResource(cwd: string, path: string | null | undefined) {
208+
return { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };
209+
}
210+
203211
export function createProjectFaviconUrlAtomFamily(input: {
204212
readonly imageCache?: ProjectFaviconCache;
205213
readonly createUrl: (target: {
@@ -219,8 +227,10 @@ export function createProjectFaviconUrlAtomFamily(input: {
219227
const projectClones = input.projectClones;
220228
const family = Atom.family((key: string) => {
221229
const [environmentId, cwd, path] = decodeKey(JSON.parse(key));
222-
const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };
223-
const query = input.createUrl({ environmentId, input: { resource } });
230+
const query = input.createUrl({
231+
environmentId,
232+
input: { resource: projectFaviconResource(cwd, path) },
233+
});
224234
// A cloned project exists before its files do, and the server reports its
225235
// icon missing until the clone lands. Ask again whenever the clone's phase
226236
// changes: the first list a client sees may already say done.

0 commit comments

Comments
 (0)