Fix project icon cache reuse in New Workspace (#3600)

* fix(app): reuse project icon cache in new workspace

New Workspace omitted each host placement's effective icon revision, so its icon queries could not share the sidebar's memory or persistent cache entries. Carry that revision through the target projection.

* refactor(app): centralize project icon targets

Project icon consumers constructed equivalent cache targets independently. One target factory now carries each host placement revision through the sidebar, New Workspace, Projects, and Project Settings.

* test(app): inline project icon fixture
This commit is contained in:
Mohamed Boudra
2026-08-20 13:15:58 +02:00
committed by GitHub
parent 4dbd26c88c
commit 975dbd433b
14 changed files with 182 additions and 76 deletions
+22 -6
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createProjectIconTarget } from "./icon-target";
import { ProjectIconCache, type ProjectIconCacheStorage } from "./icon-cache"; import { ProjectIconCache, type ProjectIconCacheStorage } from "./icon-cache";
class MemoryStorage implements ProjectIconCacheStorage { class MemoryStorage implements ProjectIconCacheStorage {
@@ -12,12 +13,16 @@ class MemoryStorage implements ProjectIconCacheStorage {
} }
} }
const target = { const target = createProjectIconTarget({
serverId: "host-a", projectViewKey: "project-view",
projectId: "project-a", placement: {
iconWorkingDir: "/project-a", serverId: "host-a",
iconRevision: "revision-a", projectId: "project-a",
}; iconWorkingDir: "/project-a",
iconRevision: "revision-a",
},
});
if (!target) throw new Error("Expected project icon target");
const icon = { data: "aWNvbg==", mimeType: "image/png" }; const icon = { data: "aWNvbg==", mimeType: "image/png" };
function clientWith(iconResult: typeof icon | null) { function clientWith(iconResult: typeof icon | null) {
@@ -27,6 +32,17 @@ function clientWith(iconResult: typeof icon | null) {
} }
describe("ProjectIconCache", () => { describe("ProjectIconCache", () => {
it("uses the shared target revision in its query key", () => {
const cache = new ProjectIconCache(new MemoryStorage());
expect(cache.query(target, true, () => null, false).queryKey).toEqual([
"projectIcon",
"host-a",
"project-a",
"revision-a",
]);
});
it("resolves, persists, and hydrates an icon before a host connects", async () => { it("resolves, persists, and hydrates an icon before a host connects", async () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const writer = new ProjectIconCache(storage); const writer = new ProjectIconCache(storage);
+1 -8
View File
@@ -1,6 +1,7 @@
import type { ProjectIcon } from "@getpaseo/protocol/messages"; import type { ProjectIcon } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { replicaCacheStorage } from "@/runtime/replica-cache/storage"; import { replicaCacheStorage } from "@/runtime/replica-cache/storage";
import type { ProjectIconTarget } from "./icon-target";
const STORAGE_KEY = "@paseo:project-icon-cache"; const STORAGE_KEY = "@paseo:project-icon-cache";
const CACHE_VERSION = 1; const CACHE_VERSION = 1;
@@ -20,14 +21,6 @@ interface ProjectIconIdentity {
type ProjectIconCacheRead = { hit: true; icon: ProjectIcon | null } | { hit: false }; type ProjectIconCacheRead = { hit: true; icon: ProjectIcon | null } | { hit: false };
interface ProjectIconTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
iconRevision?: string;
}
interface StoredIcon extends ProjectIconIdentity { interface StoredIcon extends ProjectIconIdentity {
icon: ProjectIcon | null; icon: ProjectIcon | null;
} }
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { createProjectIconTarget } from "./icon-target";
describe("createProjectIconTarget", () => {
it("keeps the host effective revision in the shared cache identity", () => {
expect(
createProjectIconTarget({
projectViewKey: "project-view",
placement: {
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: " /work/app ",
customIconRevision: "custom-revision",
iconRevision: "effective-revision",
},
}),
).toEqual({
projectViewKey: "project-view",
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/work/app",
customIconRevision: "custom-revision",
iconRevision: "effective-revision",
});
});
});
+27
View File
@@ -0,0 +1,27 @@
export interface ProjectIconPlacement {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
iconRevision?: string;
}
export interface ProjectIconTarget extends ProjectIconPlacement {
projectViewKey: string;
}
export function createProjectIconTarget(input: {
projectViewKey: string;
placement: ProjectIconPlacement;
}): ProjectIconTarget | null {
const iconWorkingDir = input.placement.iconWorkingDir.trim();
if (!input.placement.serverId || !input.placement.projectId || !iconWorkingDir) return null;
return {
projectViewKey: input.projectViewKey,
serverId: input.placement.serverId,
projectId: input.placement.projectId,
iconWorkingDir,
customIconRevision: input.placement.customIconRevision,
iconRevision: input.placement.iconRevision,
};
}
+1 -9
View File
@@ -4,6 +4,7 @@ import { useQueries, useQuery } from "@tanstack/react-query";
import type { ProjectIcon } from "@getpaseo/protocol/messages"; import type { ProjectIcon } from "@getpaseo/protocol/messages";
import { useHostFeatureAvailabilityMap } from "@/runtime/host-features"; import { useHostFeatureAvailabilityMap } from "@/runtime/host-features";
import { projectIconCache } from "@/projects/icon-cache"; import { projectIconCache } from "@/projects/icon-cache";
import type { ProjectIconTarget } from "@/projects/icon-target";
import { import {
getHostRuntimeStore, getHostRuntimeStore,
isHostRuntimeConnected, isHostRuntimeConnected,
@@ -11,15 +12,6 @@ import {
useHostRuntimeIsConnected, useHostRuntimeIsConnected,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
interface ProjectIconTarget {
serverId: string;
projectViewKey: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
iconRevision?: string;
}
/** /**
* Daemons without custom-icon support only answer the legacy cwd lookup, which * Daemons without custom-icon support only answer the legacy cwd lookup, which
* still serves their automatically discovered icons. * still serves their automatically discovered icons.
@@ -116,6 +116,7 @@ import {
resolveNewWorkspaceAutomaticServerId, resolveNewWorkspaceAutomaticServerId,
resolveNewWorkspaceInitialServerId, resolveNewWorkspaceInitialServerId,
} from "./new-workspace-initial-context"; } from "./new-workspace-initial-context";
import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets";
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
const ThemedFolderPlus = withUnistyles(FolderPlus); const ThemedFolderPlus = withUnistyles(FolderPlus);
@@ -1646,24 +1647,7 @@ export function NewWorkspaceScreen({
allowAllProjects: supportsWorkspaceMultiplicity, allowAllProjects: supportsWorkspaceMultiplicity,
}); });
const projectIconTargets = useMemo( const projectIconTargets = useMemo(
() => () => buildNewWorkspaceProjectIconTargets(projects, selectedServerId),
projects.flatMap((project) => {
const iconWorkingDir = getHostProjectSourceDirectory(project, selectedServerId)?.trim();
if (!iconWorkingDir) {
return [];
}
const host = project.hosts.find((candidate) => candidate.serverId === selectedServerId);
if (!host) return [];
return [
{
projectViewKey: project.viewKey,
projectId: host.projectId,
serverId: selectedServerId,
iconWorkingDir,
customIconRevision: host.customIconRevision,
},
];
}),
[projects, selectedServerId], [projects, selectedServerId],
); );
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { HostProjectListItem } from "@/projects/host-projects";
import { buildNewWorkspaceProjectIconTargets } from "./project-icon-targets";
const project: HostProjectListItem = {
viewKey: "project-view",
projectKey: "remote:github.com/acme/app",
projectName: "app",
projectKind: "git",
iconWorkingDir: "/work/app",
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/work/app",
worktreeSupport: "supported",
customIconRevision: "custom-revision",
iconRevision: "effective-revision",
},
],
workspaceKeys: [],
};
describe("buildNewWorkspaceProjectIconTargets", () => {
it("uses the host effective revision for the sidebar's project-icon cache identity", () => {
expect(buildNewWorkspaceProjectIconTargets([project], "host-a")).toEqual([
{
projectViewKey: "project-view",
projectId: "project-a",
serverId: "host-a",
iconWorkingDir: "/work/app",
customIconRevision: "custom-revision",
iconRevision: "effective-revision",
},
]);
});
});
@@ -0,0 +1,14 @@
import type { HostProjectListItem } from "@/projects/host-projects";
import { createProjectIconTarget, type ProjectIconTarget } from "@/projects/icon-target";
export function buildNewWorkspaceProjectIconTargets(
projects: readonly HostProjectListItem[],
serverId: string,
): ProjectIconTarget[] {
return projects.flatMap((project) => {
const host = project.hosts.find((candidate) => candidate.serverId === serverId);
if (!host) return [];
const target = createProjectIconTarget({ projectViewKey: project.viewKey, placement: host });
return target ? [target] : [];
});
}
@@ -34,6 +34,7 @@ import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import type { ProjectEditFormSnapshot } from "@/projects/edit-form"; import type { ProjectEditFormSnapshot } from "@/projects/edit-form";
import { useProjectIcons } from "@/projects/icons"; import { useProjectIcons } from "@/projects/icons";
import { createProjectIconTarget } from "@/projects/icon-target";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features"; import { useHostFeature } from "@/runtime/host-features";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
@@ -230,24 +231,13 @@ function ProjectSettingsBody({
const data = readQuery.data; const data = readQuery.data;
const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon"); const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon");
const customIconRevision = selectedHost.customIconRevision ?? null; const customIconRevision = selectedHost.customIconRevision ?? null;
const projectIconTargets = useMemo( const projectIconTargets = useMemo(() => {
() => [ const target = createProjectIconTarget({
{ projectViewKey: project.viewKey,
serverId: selectedHost.serverId, placement: { ...selectedHost, iconWorkingDir: selectedHost.repoRoot },
projectViewKey: project.viewKey, });
projectId: selectedHost.projectId, return target ? [target] : [];
iconWorkingDir: selectedHost.repoRoot, }, [project.viewKey, selectedHost]);
customIconRevision,
},
],
[
customIconRevision,
project.viewKey,
selectedHost.projectId,
selectedHost.repoRoot,
selectedHost.serverId,
],
);
const projectIcons = useProjectIcons({ projects: projectIconTargets }); const projectIcons = useProjectIcons({ projects: projectIconTargets });
const projectIconDataUri = projectIcons.get(project.viewKey) ?? null; const projectIconDataUri = projectIcons.get(project.viewKey) ?? null;
const editSnapshot = useMemo<ProjectEditFormSnapshot>( const editSnapshot = useMemo<ProjectEditFormSnapshot>(
+8 -7
View File
@@ -7,6 +7,7 @@ import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIcons } from "@/projects/icons"; import { useProjectIcons } from "@/projects/icons";
import { createProjectIconTarget } from "@/projects/icon-target";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { openProjectSettings } from "@/navigation/settings-navigation"; import { openProjectSettings } from "@/navigation/settings-navigation";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
@@ -35,13 +36,13 @@ export default function ProjectsScreen({ serverId }: ProjectsScreenProps) {
const scopedErrors = hostErrors.filter((error) => error.serverId === serverId); const scopedErrors = hostErrors.filter((error) => error.serverId === serverId);
const iconTargets = useMemo( const iconTargets = useMemo(
() => () =>
hostProjects.map(({ project, host }) => ({ hostProjects.flatMap(({ project, host }) => {
serverId: host.serverId, const target = createProjectIconTarget({
projectViewKey: project.viewKey, projectViewKey: project.viewKey,
projectId: host.projectId, placement: { ...host, iconWorkingDir: host.repoRoot },
iconWorkingDir: host.repoRoot, });
customIconRevision: host.customIconRevision, return target ? [target] : [];
})), }),
[hostProjects], [hostProjects],
); );
const iconDataByProjectViewKey = useProjectIcons({ const iconDataByProjectViewKey = useProjectIcons({
+19 -5
View File
@@ -3,12 +3,18 @@ import { createProjectViewKey } from "@/projects/workspace-structure";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, getProjectSummaryForHostProject } from "./projects"; import { buildProjects, getProjectSummaryForHostProject } from "./projects";
function descriptor(id: string, key: string, root: string): ProjectDescriptor { function descriptor(
id: string,
key: string,
root: string,
iconRevision?: string,
): ProjectDescriptor {
return { return {
projectId: id, projectId: id,
projectKey: key, projectKey: key,
projectDisplayName: "acme/app", projectDisplayName: "acme/app",
projectCustomName: null, projectCustomName: null,
projectIconRevision: iconRevision,
projectRootPath: root, projectRootPath: root,
projectKind: "git", projectKind: "git",
}; };
@@ -42,14 +48,14 @@ describe("buildProjects", () => {
serverId: "host-a", serverId: "host-a",
serverName: "Host A", serverName: "Host A",
isOnline: true, isOnline: true,
projects: [descriptor("prj_a", key, "/a/app")], projects: [descriptor("prj_a", key, "/a/app", "revision-a")],
workspaces: [workspace("ws-a", "prj_a", "/a/app")], workspaces: [workspace("ws-a", "prj_a", "/a/app")],
}, },
{ {
serverId: "host-b", serverId: "host-b",
serverName: "Host B", serverName: "Host B",
isOnline: false, isOnline: false,
projects: [descriptor("prj_b", key, "/b/app")], projects: [descriptor("prj_b", key, "/b/app", "revision-b")],
workspaces: [workspace("ws-b", "prj_b", "/b/app")], workspaces: [workspace("ws-b", "prj_b", "/b/app")],
}, },
], ],
@@ -62,8 +68,16 @@ describe("buildProjects", () => {
onlineHostCount: 1, onlineHostCount: 1,
totalWorkspaceCount: 2, totalWorkspaceCount: 2,
hosts: [ hosts: [
expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }), expect.objectContaining({
expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }), serverId: "host-a",
projectId: "prj_a",
iconRevision: "revision-a",
}),
expect.objectContaining({
serverId: "host-b",
projectId: "prj_b",
iconRevision: "revision-b",
}),
], ],
}), }),
]); ]);
+4
View File
@@ -25,6 +25,7 @@ export interface ProjectHostEntry {
gitRuntime?: WorkspaceDescriptor["gitRuntime"]; gitRuntime?: WorkspaceDescriptor["gitRuntime"];
githubRuntime?: WorkspaceDescriptor["githubRuntime"]; githubRuntime?: WorkspaceDescriptor["githubRuntime"];
customIconRevision?: string | null; customIconRevision?: string | null;
iconRevision?: string;
} }
export interface ProjectSummary { export interface ProjectSummary {
@@ -87,6 +88,7 @@ interface HostGroup {
isOnline: boolean; isOnline: boolean;
workspaces: WorkspaceDescriptor[]; workspaces: WorkspaceDescriptor[];
customIconRevision?: string | null; customIconRevision?: string | null;
iconRevision?: string;
// Repo root for a project parent that has no workspaces yet. Without it the // Repo root for a project parent that has no workspaces yet. Without it the
// host's repoRoot resolves to "" and the project reads as non-editable. // host's repoRoot resolves to "" and the project reads as non-editable.
fallbackRepoRoot: string; fallbackRepoRoot: string;
@@ -159,6 +161,7 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
gitRuntime: canonical?.gitRuntime, gitRuntime: canonical?.gitRuntime,
githubRuntime: canonical?.githubRuntime, githubRuntime: canonical?.githubRuntime,
customIconRevision: canonical?.projectCustomIconRevision ?? group.customIconRevision, customIconRevision: canonical?.projectCustomIconRevision ?? group.customIconRevision,
iconRevision: group.iconRevision,
}; };
} }
@@ -223,6 +226,7 @@ function addHostProjects(
isOnline: host.isOnline, isOnline: host.isOnline,
workspaces: [], workspaces: [],
customIconRevision: placement.customIconRevision, customIconRevision: placement.customIconRevision,
iconRevision: placement.iconRevision,
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "", fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
}); });
} }
@@ -232,7 +232,12 @@ describe("buildSidebarProjectRowModel", () => {
project({ project({
viewKey: '["placement","host-b","project-b"]', viewKey: '["placement","host-b","project-b"]',
hosts: [ hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const }, {
serverId: "host-b",
iconWorkingDir: "/repo/b",
worktreeSupport: "supported" as const,
iconRevision: "effective-revision",
},
], ],
}), }),
]); ]);
@@ -242,6 +247,7 @@ describe("buildSidebarProjectRowModel", () => {
serverId: "host-b", serverId: "host-b",
projectId: "project-host-b", projectId: "project-host-b",
iconWorkingDir: "/repo/b", iconWorkingDir: "/repo/b",
iconRevision: "effective-revision",
}); });
}); });
@@ -1,4 +1,5 @@
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { createProjectIconTarget, type ProjectIconTarget } from "@/projects/icon-target";
export interface SidebarProjectHostTarget { export interface SidebarProjectHostTarget {
serverId: string; serverId: string;
@@ -53,16 +54,17 @@ export function resolveSidebarProjectIconTarget(
return null; return null;
} }
export interface SidebarProjectIconTarget extends SidebarProjectHostTarget { export type SidebarProjectIconTarget = ProjectIconTarget;
projectViewKey: string;
}
export function resolveSidebarProjectIconTargets( export function resolveSidebarProjectIconTargets(
projects: readonly SidebarProjectEntry[], projects: readonly SidebarProjectEntry[],
): SidebarProjectIconTarget[] { ): SidebarProjectIconTarget[] {
return projects.flatMap((project) => { return projects.flatMap((project) => {
const target = resolveSidebarProjectIconTarget(project); const target = resolveSidebarProjectIconTarget(project);
return target ? [{ projectViewKey: project.viewKey, ...target }] : []; const iconTarget = target
? createProjectIconTarget({ projectViewKey: project.viewKey, placement: target })
: null;
return iconTarget ? [iconTarget] : [];
}); });
} }