From 975dbd433b4500db359887575189f71d2b54132a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 20 Aug 2026 13:15:58 +0200 Subject: [PATCH] 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 --- packages/app/src/projects/icon-cache.test.ts | 28 +++++++++++--- packages/app/src/projects/icon-cache.ts | 9 +---- packages/app/src/projects/icon-target.test.ts | 26 +++++++++++++ packages/app/src/projects/icon-target.ts | 27 ++++++++++++++ packages/app/src/projects/icons.ts | 10 +---- .../app/src/screens/new-workspace-screen.tsx | 20 +--------- .../project-icon-targets.test.ts | 37 +++++++++++++++++++ .../new-workspace/project-icon-targets.ts | 14 +++++++ .../src/screens/project-settings-screen.tsx | 26 ++++--------- packages/app/src/screens/projects-screen.tsx | 15 ++++---- packages/app/src/utils/projects.test.ts | 24 +++++++++--- packages/app/src/utils/projects.ts | 4 ++ .../utils/sidebar-project-row-model.test.ts | 8 +++- .../src/utils/sidebar-project-row-model.ts | 10 +++-- 14 files changed, 182 insertions(+), 76 deletions(-) create mode 100644 packages/app/src/projects/icon-target.test.ts create mode 100644 packages/app/src/projects/icon-target.ts create mode 100644 packages/app/src/screens/new-workspace/project-icon-targets.test.ts create mode 100644 packages/app/src/screens/new-workspace/project-icon-targets.ts diff --git a/packages/app/src/projects/icon-cache.test.ts b/packages/app/src/projects/icon-cache.test.ts index 92df4c409..403192e35 100644 --- a/packages/app/src/projects/icon-cache.test.ts +++ b/packages/app/src/projects/icon-cache.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { createProjectIconTarget } from "./icon-target"; import { ProjectIconCache, type ProjectIconCacheStorage } from "./icon-cache"; class MemoryStorage implements ProjectIconCacheStorage { @@ -12,12 +13,16 @@ class MemoryStorage implements ProjectIconCacheStorage { } } -const target = { - serverId: "host-a", - projectId: "project-a", - iconWorkingDir: "/project-a", - iconRevision: "revision-a", -}; +const target = createProjectIconTarget({ + projectViewKey: "project-view", + placement: { + serverId: "host-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" }; function clientWith(iconResult: typeof icon | null) { @@ -27,6 +32,17 @@ function clientWith(iconResult: typeof icon | null) { } 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 () => { const storage = new MemoryStorage(); const writer = new ProjectIconCache(storage); diff --git a/packages/app/src/projects/icon-cache.ts b/packages/app/src/projects/icon-cache.ts index 93761708c..d409a59ba 100644 --- a/packages/app/src/projects/icon-cache.ts +++ b/packages/app/src/projects/icon-cache.ts @@ -1,6 +1,7 @@ import type { ProjectIcon } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { replicaCacheStorage } from "@/runtime/replica-cache/storage"; +import type { ProjectIconTarget } from "./icon-target"; const STORAGE_KEY = "@paseo:project-icon-cache"; const CACHE_VERSION = 1; @@ -20,14 +21,6 @@ interface ProjectIconIdentity { 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 { icon: ProjectIcon | null; } diff --git a/packages/app/src/projects/icon-target.test.ts b/packages/app/src/projects/icon-target.test.ts new file mode 100644 index 000000000..07f8f8d68 --- /dev/null +++ b/packages/app/src/projects/icon-target.test.ts @@ -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", + }); + }); +}); diff --git a/packages/app/src/projects/icon-target.ts b/packages/app/src/projects/icon-target.ts new file mode 100644 index 000000000..09e2887d8 --- /dev/null +++ b/packages/app/src/projects/icon-target.ts @@ -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, + }; +} diff --git a/packages/app/src/projects/icons.ts b/packages/app/src/projects/icons.ts index 92bef909a..ee52b2b57 100644 --- a/packages/app/src/projects/icons.ts +++ b/packages/app/src/projects/icons.ts @@ -4,6 +4,7 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import type { ProjectIcon } from "@getpaseo/protocol/messages"; import { useHostFeatureAvailabilityMap } from "@/runtime/host-features"; import { projectIconCache } from "@/projects/icon-cache"; +import type { ProjectIconTarget } from "@/projects/icon-target"; import { getHostRuntimeStore, isHostRuntimeConnected, @@ -11,15 +12,6 @@ import { useHostRuntimeIsConnected, } 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 * still serves their automatically discovered icons. diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ca3c9136f..288a763fe 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -116,6 +116,7 @@ import { resolveNewWorkspaceAutomaticServerId, resolveNewWorkspaceInitialServerId, } from "./new-workspace-initial-context"; +import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; const ThemedFolderPlus = withUnistyles(FolderPlus); @@ -1646,24 +1647,7 @@ export function NewWorkspaceScreen({ allowAllProjects: supportsWorkspaceMultiplicity, }); const projectIconTargets = useMemo( - () => - 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, - }, - ]; - }), + () => buildNewWorkspaceProjectIconTargets(projects, selectedServerId), [projects, selectedServerId], ); diff --git a/packages/app/src/screens/new-workspace/project-icon-targets.test.ts b/packages/app/src/screens/new-workspace/project-icon-targets.test.ts new file mode 100644 index 000000000..cb1d3616c --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-icon-targets.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/packages/app/src/screens/new-workspace/project-icon-targets.ts b/packages/app/src/screens/new-workspace/project-icon-targets.ts new file mode 100644 index 000000000..ff3fb1d5c --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-icon-targets.ts @@ -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] : []; + }); +} diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index b104c26bf..591c95526 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -34,6 +34,7 @@ import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; import type { ProjectEditFormSnapshot } from "@/projects/edit-form"; import { useProjectIcons } from "@/projects/icons"; +import { createProjectIconTarget } from "@/projects/icon-target"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useHostFeature } from "@/runtime/host-features"; import { useToast } from "@/contexts/toast-context"; @@ -230,24 +231,13 @@ function ProjectSettingsBody({ const data = readQuery.data; const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon"); const customIconRevision = selectedHost.customIconRevision ?? null; - const projectIconTargets = useMemo( - () => [ - { - serverId: selectedHost.serverId, - projectViewKey: project.viewKey, - projectId: selectedHost.projectId, - iconWorkingDir: selectedHost.repoRoot, - customIconRevision, - }, - ], - [ - customIconRevision, - project.viewKey, - selectedHost.projectId, - selectedHost.repoRoot, - selectedHost.serverId, - ], - ); + const projectIconTargets = useMemo(() => { + const target = createProjectIconTarget({ + projectViewKey: project.viewKey, + placement: { ...selectedHost, iconWorkingDir: selectedHost.repoRoot }, + }); + return target ? [target] : []; + }, [project.viewKey, selectedHost]); const projectIcons = useProjectIcons({ projects: projectIconTargets }); const projectIconDataUri = projectIcons.get(project.viewKey) ?? null; const editSnapshot = useMemo( diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index 4f89253a2..0220bbf43 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -7,6 +7,7 @@ import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { useProjectIcons } from "@/projects/icons"; +import { createProjectIconTarget } from "@/projects/icon-target"; import { settingsStyles } from "@/styles/settings"; import { openProjectSettings } from "@/navigation/settings-navigation"; 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 iconTargets = useMemo( () => - hostProjects.map(({ project, host }) => ({ - serverId: host.serverId, - projectViewKey: project.viewKey, - projectId: host.projectId, - iconWorkingDir: host.repoRoot, - customIconRevision: host.customIconRevision, - })), + hostProjects.flatMap(({ project, host }) => { + const target = createProjectIconTarget({ + projectViewKey: project.viewKey, + placement: { ...host, iconWorkingDir: host.repoRoot }, + }); + return target ? [target] : []; + }), [hostProjects], ); const iconDataByProjectViewKey = useProjectIcons({ diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 926b59c30..69b9d2872 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -3,12 +3,18 @@ import { createProjectViewKey } from "@/projects/workspace-structure"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; 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 { projectId: id, projectKey: key, projectDisplayName: "acme/app", projectCustomName: null, + projectIconRevision: iconRevision, projectRootPath: root, projectKind: "git", }; @@ -42,14 +48,14 @@ describe("buildProjects", () => { serverId: "host-a", serverName: "Host A", 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")], }, { serverId: "host-b", serverName: "Host B", 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")], }, ], @@ -62,8 +68,16 @@ describe("buildProjects", () => { onlineHostCount: 1, totalWorkspaceCount: 2, hosts: [ - expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }), - expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }), + expect.objectContaining({ + serverId: "host-a", + projectId: "prj_a", + iconRevision: "revision-a", + }), + expect.objectContaining({ + serverId: "host-b", + projectId: "prj_b", + iconRevision: "revision-b", + }), ], }), ]); diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index 97daef8f2..563e79b0d 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -25,6 +25,7 @@ export interface ProjectHostEntry { gitRuntime?: WorkspaceDescriptor["gitRuntime"]; githubRuntime?: WorkspaceDescriptor["githubRuntime"]; customIconRevision?: string | null; + iconRevision?: string; } export interface ProjectSummary { @@ -87,6 +88,7 @@ interface HostGroup { isOnline: boolean; workspaces: WorkspaceDescriptor[]; customIconRevision?: string | null; + iconRevision?: string; // 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. fallbackRepoRoot: string; @@ -159,6 +161,7 @@ function toHostEntry(group: HostGroup): ProjectHostEntry { gitRuntime: canonical?.gitRuntime, githubRuntime: canonical?.githubRuntime, customIconRevision: canonical?.projectCustomIconRevision ?? group.customIconRevision, + iconRevision: group.iconRevision, }; } @@ -223,6 +226,7 @@ function addHostProjects( isOnline: host.isOnline, workspaces: [], customIconRevision: placement.customIconRevision, + iconRevision: placement.iconRevision, fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "", }); } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index fdd917355..de23319c9 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -232,7 +232,12 @@ describe("buildSidebarProjectRowModel", () => { project({ viewKey: '["placement","host-b","project-b"]', 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", projectId: "project-host-b", iconWorkingDir: "/repo/b", + iconRevision: "effective-revision", }); }); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 4a9bf0a8b..619e08dc4 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -1,4 +1,5 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { createProjectIconTarget, type ProjectIconTarget } from "@/projects/icon-target"; export interface SidebarProjectHostTarget { serverId: string; @@ -53,16 +54,17 @@ export function resolveSidebarProjectIconTarget( return null; } -export interface SidebarProjectIconTarget extends SidebarProjectHostTarget { - projectViewKey: string; -} +export type SidebarProjectIconTarget = ProjectIconTarget; export function resolveSidebarProjectIconTargets( projects: readonly SidebarProjectEntry[], ): SidebarProjectIconTarget[] { return projects.flatMap((project) => { const target = resolveSidebarProjectIconTarget(project); - return target ? [{ projectViewKey: project.viewKey, ...target }] : []; + const iconTarget = target + ? createProjectIconTarget({ projectViewKey: project.viewKey, placement: target }) + : null; + return iconTarget ? [iconTarget] : []; }); }