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] : []; }); }