feat(project): add icon_url_override field to projects (#23955)

This commit is contained in:
Brendan Allan 2026-04-23 18:57:04 +08:00 committed by GitHub
parent 785f3589ab
commit 6002500bc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1545 additions and 77 deletions

View File

@ -10,9 +10,8 @@ import type {
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path" import { getFilename } from "@opencode-ai/shared/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile, unwrap } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap" import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
@ -24,7 +23,6 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query" import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
@ -56,15 +54,10 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
const [globalStore, setGlobalStore] = createStore<GlobalStore>({ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
ready: false, ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value, project: [],
session_todo: {}, session_todo: {},
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
provider_auth: {}, provider_auth: {},
@ -73,32 +66,18 @@ function createGlobalSync() {
}) })
const queryClient = useQueryClient() const queryClient = useQueryClient()
let active = true
let projectWritten = false
let bootedAt = 0 let bootedAt = 0
let bootingRoot = false let bootingRoot = false
let eventFrame: number | undefined let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => { onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer) if (eventTimer !== undefined) clearTimeout(eventTimer)
}) })
const cacheProjects = () => {
setProjectCache(
"value",
untrack(() => globalStore.project.map(sanitizeProject)),
)
}
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
projectWritten = true
setGlobalStore("project", next) setGlobalStore("project", next)
cacheProjects()
} }
const setBootStore = ((...input: unknown[]) => { const setBootStore = ((...input: unknown[]) => {
@ -117,16 +96,6 @@ function createGlobalSync() {
return (setGlobalStore as (...args: unknown[]) => unknown)(...input) return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore }) as typeof setGlobalStore
if (projectInit instanceof Promise) {
void projectInit.then(() => {
if (!active) return
if (projectWritten) return
const cached = projectCache.value
if (cached.length === 0) return
setGlobalStore("project", cached)
})
}
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return if (!sessionID) return
if (!todos) { if (!todos) {

View File

@ -156,13 +156,12 @@ export function createChildStoreManager(input: {
const init = () => const init = () =>
createRoot((dispose) => { createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value const initialIcon = icon[0].value
const pathQuery = useQuery(() => loadPathQuery(directory)) const pathQuery = useQuery(() => loadPathQuery(directory))
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
projectMeta: initialMeta, projectMeta: undefined,
icon: initialIcon, icon: initialIcon,
provider_ready: false, provider_ready: false,
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
@ -208,11 +207,6 @@ export function createChildStoreManager(input: {
child[1]("vcs", (value) => value ?? cached) child[1]("vcs", (value) => value ?? cached)
}) })
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value)
})
onPersistedInit(icon[2], () => { onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value) child[1]("icon", icon[0].value)

View File

@ -391,37 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
? globalSync.data.project.find((x) => x.id === projectID) ? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree) : globalSync.data.project.find((x) => x.worktree === project.worktree)
const local = childStore.projectMeta return { ...metadata, ...project }
const localOverride =
local?.name !== undefined ||
local?.commands?.start !== undefined ||
local?.icon?.override !== undefined ||
local?.icon?.color !== undefined
const base = {
...metadata,
...project,
icon: {
url: metadata?.icon?.url,
override: metadata?.icon?.override ?? childStore.icon,
color: metadata?.icon?.color,
},
}
const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
if (!isGlobal) return base
return {
...base,
id: base.id ?? "global",
name: local?.name,
commands: local?.commands,
icon: {
url: base.icon?.url,
override: local?.icon?.override,
color: local?.icon?.color,
},
}
} }
const roots = createMemo(() => { const roots = createMemo(() => {
@ -516,7 +486,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
for (const project of projects) { for (const project of projects) {
if (project.icon?.color || project.icon.url) continue if (project.icon?.color || project.icon?.override || project.icon?.url) continue
const worktree = project.worktree const worktree = project.worktree
const existing = colors[worktree] const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used) const color = existing ?? pickAvailableColor(used)

View File

@ -22,9 +22,7 @@ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
return id === OPENCODE_PROJECT_ID return id === OPENCODE_PROJECT_ID
? "https://opencode.ai/favicon.svg" ? "https://opencode.ai/favicon.svg"
: icon?.color : (icon?.override ?? (icon?.color ? undefined : icon?.url))
? undefined
: icon?.override || icon?.url
} }
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {

View File

@ -0,0 +1,2 @@
ALTER TABLE `project` ADD `icon_url_override` text;
UPDATE `project` SET `icon_url_override` = `icon_url` WHERE `icon_url` IS NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ export const ProjectTable = sqliteTable("project", {
vcs: text(), vcs: text(),
name: text(), name: text(),
icon_url: text(), icon_url: text(),
icon_url_override: text(),
icon_color: text(), icon_color: text(),
...Timestamps, ...Timestamps,
time_initialized: integer(), time_initialized: integer(),

View File

@ -60,7 +60,13 @@ type Row = typeof ProjectTable.$inferSelect
export function fromRow(row: Row): Info { export function fromRow(row: Row): Info {
const icon = const icon =
row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined row.icon_url || row.icon_url_override || row.icon_color
? {
url: row.icon_url ?? undefined,
override: row.icon_url_override ?? undefined,
color: row.icon_color ?? undefined,
}
: undefined
return { return {
id: row.id, id: row.id,
worktree: row.worktree, worktree: row.worktree,
@ -289,6 +295,7 @@ export const layer: Layer.Layer<
vcs: result.vcs ?? null, vcs: result.vcs ?? null,
name: result.name, name: result.name,
icon_url: result.icon?.url, icon_url: result.icon?.url,
icon_url_override: result.icon?.override,
icon_color: result.icon?.color, icon_color: result.icon?.color,
time_created: result.time.created, time_created: result.time.created,
time_updated: result.time.updated, time_updated: result.time.updated,
@ -303,6 +310,7 @@ export const layer: Layer.Layer<
vcs: result.vcs ?? null, vcs: result.vcs ?? null,
name: result.name, name: result.name,
icon_url: result.icon?.url, icon_url: result.icon?.url,
icon_url_override: result.icon?.override,
icon_color: result.icon?.color, icon_color: result.icon?.color,
time_updated: result.time.updated, time_updated: result.time.updated,
time_initialized: result.time.initialized, time_initialized: result.time.initialized,
@ -365,6 +373,7 @@ export const layer: Layer.Layer<
.set({ .set({
name: input.name, name: input.name,
icon_url: input.icon?.url, icon_url: input.icon?.url,
icon_url_override: input.icon?.override,
icon_color: input.icon?.color, icon_color: input.icon?.color,
commands: input.commands, commands: input.commands,
time_updated: Date.now(), time_updated: Date.now(),

View File

@ -168,6 +168,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
vcs: data.vcs, vcs: data.vcs,
name: data.name ?? undefined, name: data.name ?? undefined,
icon_url: data.icon?.url, icon_url: data.icon?.url,
icon_url_override: data.icon?.override,
icon_color: data.icon?.color, icon_color: data.icon?.color,
time_created: data.time?.created ?? now, time_created: data.time?.created ?? now,
time_updated: data.time?.updated ?? now, time_updated: data.time?.updated ?? now,

View File

@ -278,6 +278,31 @@ describe("Project.discover", () => {
expect(updated).toBeDefined() expect(updated).toBeDefined()
expect(updated!.icon).toBeUndefined() expect(updated!.icon).toBeUndefined()
}) })
test("should not discover favicon when override is set", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
await run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
}),
)
const updatedProject = await run((svc) => svc.get(project.id))
if (!updatedProject) throw new Error("Project not found")
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await run((svc) => svc.discover(updatedProject))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon?.override).toBe("data:image/png;base64,override")
expect(updated!.icon?.url).toBeUndefined()
})
}) })
describe("Project.update", () => { describe("Project.update", () => {
@ -332,6 +357,23 @@ describe("Project.update", () => {
expect(fromDb?.icon?.color).toBe("#ff0000") expect(fromDb?.icon?.color).toBe("#ff0000")
}) })
test("should update icon override", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
}),
)
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
})
test("should update commands", async () => { test("should update commands", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path)) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
@ -389,13 +431,14 @@ describe("Project.update", () => {
svc.update({ svc.update({
projectID: project.id, projectID: project.id,
name: "Multi Update", name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" }, icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" }, commands: { start: "make start" },
}), }),
) )
expect(updated.name).toBe("Multi Update") expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico") expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
expect(updated.icon?.color).toBe("#00ff00") expect(updated.icon?.color).toBe("#00ff00")
expect(updated.commands?.start).toBe("make start") expect(updated.commands?.start).toBe("make start")
}) })