Skip to content

Commit 764cbd6

Browse files
committed
fix(app): persist local project renames in project list
1 parent 6c32991 commit 764cbd6

4 files changed

Lines changed: 133 additions & 16 deletions

File tree

packages/app/src/context/global-sync/utils.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import type {
55
ModelListOutput,
66
ProviderListOutput,
77
} from "@opencode-ai/client/promise"
8-
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
8+
import {
9+
directoryKey,
10+
enrichProject,
11+
mergeProjectMeta,
12+
normalizeAgentList,
13+
normalizePermissionRequest,
14+
normalizeProviderList,
15+
} from "./utils"
916

1017
describe("normalizeAgentList", () => {
1118
test("adapts current agents to the app agent shape", () => {
@@ -131,3 +138,86 @@ describe("directoryKey", () => {
131138
expect(String(directoryKey("/"))).toBe("/")
132139
})
133140
})
141+
142+
type Base = {
143+
worktree: string
144+
name?: string
145+
icon?: { color?: string; override?: string }
146+
commands?: { start?: string }
147+
}
148+
149+
describe("mergeProjectMeta", () => {
150+
test("returns base unchanged when there is no local projectMeta", () => {
151+
const base: Base = { worktree: "/repo", name: "Repo" }
152+
expect(mergeProjectMeta(base, undefined)).toBe(base)
153+
})
154+
155+
test("applies local name for projects without a server registration", () => {
156+
const base: Base = { worktree: "/repo" }
157+
expect(mergeProjectMeta(base, { name: "MicroservicesABC" })).toEqual({
158+
worktree: "/repo",
159+
name: "MicroservicesABC",
160+
})
161+
})
162+
163+
test("keeps server name when local projectMeta has no name", () => {
164+
const base: Base = { worktree: "/repo", name: "SOOK" }
165+
expect(mergeProjectMeta(base, { icon: { color: "red" } })).toEqual({
166+
worktree: "/repo",
167+
name: "SOOK",
168+
icon: { color: "red" },
169+
})
170+
})
171+
172+
test("preserves an empty local name (display falls back to the folder name downstream)", () => {
173+
const base: Base = { worktree: "/repo" }
174+
expect(mergeProjectMeta(base, { name: "" })).toEqual({ worktree: "/repo", name: "" })
175+
})
176+
177+
test("merges icon and commands overrides", () => {
178+
const base: Base = { worktree: "/repo", icon: { color: "blue" }, commands: { start: "" } }
179+
expect(
180+
mergeProjectMeta(base, { icon: { color: "red", override: "data:img" }, commands: { start: "bun dev" } }),
181+
).toEqual({
182+
worktree: "/repo",
183+
icon: { color: "red", override: "data:img" },
184+
commands: { start: "bun dev" },
185+
})
186+
})
187+
})
188+
189+
describe("enrichProject", () => {
190+
test("applies the legacy icon override on top of projectMeta", () => {
191+
const base: Base = { worktree: "/repo", name: "Repo" }
192+
expect(enrichProject(base, { name: "Renamed", icon: { color: "red" } }, "data:legacy", false)).toEqual({
193+
worktree: "/repo",
194+
name: "Renamed",
195+
icon: { color: "red", override: "data:legacy" },
196+
})
197+
})
198+
199+
test("returns merged base unchanged when there is no legacy icon", () => {
200+
const base: Base = { worktree: "/repo" }
201+
expect(enrichProject(base, { name: "Renamed" }, undefined, false)).toEqual({
202+
worktree: "/repo",
203+
name: "Renamed",
204+
})
205+
})
206+
207+
test("ignores stale local projectMeta for projects with a server id", () => {
208+
const base: Base = { worktree: "/repo", name: "opencode_configaaa" }
209+
expect(enrichProject(base, { name: "opencode_config" }, undefined, true)).toEqual({
210+
worktree: "/repo",
211+
name: "opencode_configaaa",
212+
})
213+
})
214+
215+
test("still applies the legacy icon override for projects with a server id", () => {
216+
const base: Base = { worktree: "/repo", name: "opencode_configaaa" }
217+
expect(enrichProject(base, { name: "opencode_config" }, "data:legacy", true)).toEqual({
218+
worktree: "/repo",
219+
name: "opencode_configaaa",
220+
icon: { override: "data:legacy" },
221+
})
222+
})
223+
})

packages/app/src/context/global-sync/utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
99
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
1010
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
11+
import type { ProjectMeta } from "./types"
1112
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
1213

1314
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -169,3 +170,36 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
169170
vcs: project.vcs === "git" ? "git" : undefined,
170171
}
171172
}
173+
174+
type ProjectMetaShape = {
175+
name?: string
176+
icon?: { color?: string; override?: string }
177+
commands?: { start?: string }
178+
}
179+
180+
// Merge local per-workspace overrides (name, commands, icon) from the localStorage
181+
// cache (childStore.projectMeta) over the base project, then apply the legacy
182+
// childStore.icon override. For projects with a server registration the server is the
183+
// source of truth, so their local projectMeta is ignored to avoid a stale local name
184+
// overriding the persisted one. Without this, projects without a server registration
185+
// would ignore their renamed name, startup command, and icon.
186+
export function enrichProject<T extends ProjectMetaShape>(
187+
base: T,
188+
local: ProjectMeta | undefined,
189+
icon: string | undefined,
190+
hasServerId: boolean,
191+
): T {
192+
const merged = hasServerId ? base : mergeProjectMeta(base, local)
193+
if (icon) return { ...merged, icon: { ...merged.icon, override: icon } }
194+
return merged
195+
}
196+
197+
export function mergeProjectMeta<T extends ProjectMetaShape>(base: T, local: ProjectMeta | undefined): T {
198+
if (!local) return base
199+
return {
200+
...base,
201+
...(local.name !== undefined ? { name: local.name } : {}),
202+
...(local.icon ? { icon: { ...base.icon, ...local.icon } } : {}),
203+
...(local.commands ? { commands: { ...base.commands, ...local.commands } } : {}),
204+
}
205+
}

packages/app/src/context/global.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { pathKey } from "@/utils/path-key"
66
import { useServerHealth } from "@/utils/server-health"
77
import { createServerSdkContext } from "./server-sdk"
88
import { createServerSyncContext } from "./server-sync"
9+
import { enrichProject } from "./global-sync/utils"
910
import { getOwner } from "solid-js/web"
1011
import { QueryClient } from "@tanstack/solid-query"
1112
import type { ServerScope } from "@/utils/server-scope"
@@ -117,14 +118,10 @@ function createServerCtx(
117118
? sync.data.project.find((x) => x.id === projectID)
118119
: sync.data.project.find((x) => x.worktree === project.worktree)
119120

120-
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
121-
// Without this, different subdirectories of the same git repo would share the same
122-
// icon from the database instead of using their individual overrides.
121+
// Preserve local per-workspace overrides (name, commands, icon) from the localStorage
122+
// cache (childStore.projectMeta) and the legacy childStore.icon override.
123123
const base = { ...metadata, ...project }
124-
if (childStore.icon) {
125-
return { ...base, icon: { ...base.icon, override: childStore.icon } }
126-
}
127-
return base
124+
return enrichProject(base, childStore.projectMeta, childStore.icon, Boolean(projectID) && projectID !== "global")
128125
}
129126

130127
const projectsList = createMemo(() => projects.list().map(enrich))

packages/app/src/context/layout.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useServerSDK } from "./server-sdk"
88
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
99
import { usePlatform } from "./platform"
1010
import { Project } from "@opencode-ai/sdk/v2"
11-
import { normalizeProjectInfo } from "./global-sync/utils"
11+
import { enrichProject, normalizeProjectInfo } from "./global-sync/utils"
1212
import { Persist, persisted, removePersisted } from "@/utils/persist"
1313
import { pathKey } from "@/utils/path-key"
1414
import { decode64 } from "@/utils/base64"
@@ -449,14 +449,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
449449
? serverSync().data.project.find((x) => x.id === projectID)
450450
: serverSync().data.project.find((x) => x.worktree === project.worktree)
451451

452-
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
453-
// Without this, different subdirectories of the same git repo would share the same
454-
// icon from the database instead of using their individual overrides.
452+
// Preserve local per-workspace overrides (name, commands, icon) from the localStorage
453+
// cache (childStore.projectMeta) and the legacy childStore.icon override.
455454
const base = { ...metadata, ...project }
456-
if (childStore.icon) {
457-
return { ...base, icon: { ...base.icon, override: childStore.icon } }
458-
}
459-
return base
455+
return enrichProject(base, childStore.projectMeta, childStore.icon, Boolean(projectID) && projectID !== "global")
460456
}
461457

462458
const roots = createMemo(() => {

0 commit comments

Comments
 (0)