diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ec0793c540e..1bcfca2ebdf 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -26,7 +26,7 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.override || "", + iconUrl: props.project.icon?.url || props.project.icon?.override || "", startup: props.project.commands?.start ?? "", saving: false, dragOver: false, @@ -83,9 +83,8 @@ export function DialogEditProject(props: { project: LocalProject }) { if (props.project.id && props.project.id !== "global") { await globalSDK.client.project.update({ projectID: props.project.id, - directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl }, + icon: { color: store.color, url: store.iconUrl }, commands: { start }, }) globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 8b1a3c48c5f..27165a46468 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -123,7 +123,11 @@ export async function bootstrapDirectory(input: { if (input.store.status !== "complete") input.setStore("status", "loading") const blockingRequests = { - project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), + project: () => + input.sdk.project.current().then((x) => { + input.setStore("project", x.data!.id) + input.setStore("icon", x.data?.icon?.url ?? x.data?.icon?.override) + }), provider: () => input.sdk.provider.list().then((x) => { input.setStore("provider", normalizeProviderList(x.data!)) diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts index 582d5edbd29..d196f755db1 100644 --- a/packages/app/src/context/layout.test.ts +++ b/packages/app/src/context/layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" -import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout" +import { createSessionKeyReader, ensureSessionKey, pickProjectIcon, pruneSessionKeys } from "./layout" describe("layout session-key helpers", () => { test("couples touch and scroll seed in order", () => { @@ -67,3 +67,29 @@ describe("pruneSessionKeys", () => { expect(drop).toEqual([]) }) }) + +describe("pickProjectIcon", () => { + test("prefers child icon over metadata", () => { + const icon = pickProjectIcon({ + child: "child-icon", + meta: { url: "root-url", override: "root-override" }, + }) + + expect(icon.url).toBe("child-icon") + expect(icon.override).toBe("child-icon") + }) + + test("falls back to metadata url then override", () => { + const fromUrl = pickProjectIcon({ + meta: { url: "root-url", override: "root-override" }, + }) + const fromOverride = pickProjectIcon({ + meta: { override: "root-override" }, + }) + + expect(fromUrl.url).toBe("root-url") + expect(fromUrl.override).toBe("root-override") + expect(fromOverride.url).toBe("root-override") + expect(fromOverride.override).toBe("root-override") + }) +}) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 5199e5a26be..be2bb9f65a9 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -31,6 +31,12 @@ export function getAvatarColors(key?: string) { } } +export function pickProjectIcon(input: { child?: string; meta?: { url?: string; override?: string } }) { + const url = input.child ?? input.meta?.url ?? input.meta?.override + const override = input.child ?? input.meta?.override ?? input.meta?.url + return { url, override } +} + type SessionTabs = { active?: string all: string[] @@ -402,8 +408,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ...(metadata ?? {}), ...project, icon: { - url: metadata?.icon?.url, - override: metadata?.icon?.override ?? childStore.icon, + ...pickProjectIcon({ child: childStore.icon, meta: metadata?.icon }), color: metadata?.icon?.color, }, } @@ -424,60 +429,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } } - const roots = createMemo(() => { - const map = new Map() - for (const project of globalSync.data.project) { - const sandboxes = project.sandboxes ?? [] - for (const sandbox of sandboxes) { - map.set(sandbox, project.worktree) - } - } - return map - }) - - const rootFor = (directory: string) => { - const map = roots() - if (map.size === 0) return directory - - const visited = new Set() - const chain = [directory] - - while (chain.length) { - const current = chain[chain.length - 1] - if (!current) return directory - - const next = map.get(current) - if (!next) return current - - if (visited.has(next)) return directory - visited.add(next) - chain.push(next) - } - - return directory - } - - createEffect(() => { - const projects = server.projects.list() - const seen = new Set(projects.map((project) => project.worktree)) - - batch(() => { - for (const project of projects) { - const root = rootFor(project.worktree) - if (root === project.worktree) continue - - server.projects.close(project.worktree) - - if (!seen.has(root)) { - server.projects.open(root) - seen.add(root) - } - - if (project.expanded) server.projects.expand(root) - } - }) - }) - const enriched = createMemo(() => server.projects.list().map(enrich)) const list = createMemo(() => { const projects = enriched() @@ -497,7 +448,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( for (const project of projects) { if (!project.id) continue if (project.id === "global") continue - globalSync.project.icon(project.worktree, project.icon?.override) + globalSync.project.icon(project.worktree, project.icon?.url ?? project.icon?.override) } }) @@ -566,10 +517,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( projects: { list, open(directory: string) { - const root = rootFor(directory) - if (server.projects.list().find((x) => x.worktree === root)) return - globalSync.project.loadSessions(root) - server.projects.open(root) + if (server.projects.list().find((x) => x.worktree === directory)) return + globalSync.project.loadSessions(directory) + server.projects.open(directory) }, close(directory: string) { server.projects.close(directory) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 8dc03755e4a..5dc61623054 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -44,7 +44,9 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti x.trim()) + .catch(() => undefined) + if (!text) return + if (text.startsWith("data:")) return text + if (text.startsWith("http://") || text.startsWith("https://")) return text + const line = text + .split(/\r?\n/) + .map((x) => x.trim()) + .find((x) => x.toUpperCase().startsWith("URL=")) + if (!line) return + const url = line.slice(4).trim() + if (!url.startsWith("data:") && !url.startsWith("http://") && !url.startsWith("https://")) return + return url + } + + async function iconData(file: string) { + if (path.extname(file).toLowerCase() === ".url") return iconURL(file) + const mime = Filesystem.mimeType(file) + if (!mime.startsWith("image/")) return + const buffer = await Filesystem.readBytes(file) + const base64 = buffer.toString("base64") + return `data:${mime};base64,${base64}` + } + + async function configuredIcon(worktree: string) { + const files = await Glob.scan(".opencode/icon/**/*", { + cwd: worktree, + absolute: true, + include: "file", + }) + + for (const file of files.toSorted(sortPath)) { + const url = await iconData(file).catch(() => undefined) + if (!url) continue + return { url } + } + + const roots = await Glob.scan(".opencode/icon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", { + cwd: worktree, + absolute: true, + include: "file", + }) + + for (const file of roots.toSorted(sortPath)) { + const url = await iconData(file).catch(() => undefined) + if (!url) continue + return { url } + } + + const favicons = await Glob.scan(".opencode/**/favicon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", { + cwd: worktree, + absolute: true, + include: "file", + }) + + for (const file of favicons.toSorted(sortPath)) { + const url = await iconData(file).catch(() => undefined) + if (!url) continue + return { url } + } + } + export const Info = z .object({ id: z.string(), @@ -70,7 +139,7 @@ export namespace Project { export function fromRow(row: Row): Info { const icon = row.icon_url || row.icon_color - ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } + ? { url: row.icon_url ?? undefined, override: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { id: row.id, @@ -234,10 +303,33 @@ export namespace Project { return fresh }) - if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) + const icon = await configuredIcon(directory) + .then(async (item) => { + if (item) return item + if (directory === data.worktree) return + return configuredIcon(data.worktree) + }) + .then((item) => { + if (!item) return existing.icon + return { + ...existing.icon, + ...item, + override: item.url ?? existing.icon?.override, + } + }) + .catch((error) => { + log.warn("failed to load project icon from .opencode", { error, directory, worktree: data.worktree }) + return existing.icon + }) + const seeded = { + ...existing, + icon, + } + + if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(seeded) const result: Info = { - ...existing, + ...seeded, worktree: data.worktree, vcs: data.vcs as Info["vcs"], time: { @@ -253,7 +345,7 @@ export namespace Project { worktree: result.worktree, vcs: result.vcs ?? null, name: result.name, - icon_url: result.icon?.url, + icon_url: result.icon?.url ?? result.icon?.override, icon_color: result.icon?.color, time_created: result.time.created, time_updated: result.time.updated, @@ -265,7 +357,7 @@ export namespace Project { worktree: result.worktree, vcs: result.vcs ?? null, name: result.name, - icon_url: result.icon?.url, + icon_url: result.icon?.url ?? result.icon?.override, icon_color: result.icon?.color, time_updated: result.time.updated, time_initialized: result.time.initialized, @@ -288,24 +380,28 @@ export namespace Project { if (input.vcs !== "git") return if (input.icon?.override) return if (input.icon?.url) return - const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { + const preferred = await Glob.scan(".opencode/**/favicon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", { cwd: input.worktree, absolute: true, include: "file", }) - const shortest = matches.sort((a, b) => a.length - b.length)[0] - if (!shortest) return - const buffer = await Filesystem.readBytes(shortest) - const base64 = buffer.toString("base64") - const mime = Filesystem.mimeType(shortest) || "image/png" - const url = `data:${mime};base64,${base64}` - await update({ - projectID: input.id, - icon: { - url, - }, + const discovered = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp,avif,gif,url}", { + cwd: input.worktree, + absolute: true, + include: "file", }) - return + const files = [...preferred.toSorted(sortPath), ...discovered.toSorted(sortPath)] + for (const file of files) { + const url = await iconData(file).catch(() => undefined) + if (!url) continue + await update({ + projectID: input.id, + icon: { + url, + }, + }) + return + } } async function migrateFromGlobal(id: string, worktree: string) { @@ -386,7 +482,7 @@ export namespace Project { .update(ProjectTable) .set({ name: input.name, - icon_url: input.icon?.url, + icon_url: input.icon?.url ?? input.icon?.override, icon_color: input.icon?.color, commands: input.commands, time_updated: Date.now(), diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index fef9e4190e2..8ba29b3b029 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -199,9 +199,54 @@ describe("Project.fromDirectory with worktrees", () => { .catch(() => {}) } }) + + test("should prefer sandbox .opencode icon over worktree icon", async () => { + const p = await loadProject() + await using tmp = await tmpdir({ git: true }) + const child = path.join(tmp.path, "child") + const root = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02, 0x03, 0x04]) + const local = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x09, 0x08, 0x07, 0x06]) + + await Filesystem.write(path.join(tmp.path, ".opencode", "icon.png"), root) + await Filesystem.write(path.join(child, ".opencode", "icon.png"), local) + + const { project } = await p.fromDirectory(child) + + expect(project.worktree).toBe(tmp.path) + expect(project.icon?.url).toContain(local.toString("base64")) + expect(project.icon?.url).not.toContain(root.toString("base64")) + }) }) describe("Project.discover", () => { + test("should prefer icon from .opencode/icon", async () => { + const p = await loadProject() + const iconData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xaa, 0xbb, 0xcc]) + await using tmp = await tmpdir({ git: true }) + await Filesystem.write(path.join(tmp.path, ".opencode", "icon", "project-icon.png"), iconData) + await Bun.write(path.join(tmp.path, "favicon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])) + + const { project } = await p.fromDirectory(tmp.path) + + expect(project.icon?.url).toContain(iconData.toString("base64")) + expect(project.icon?.override).toContain(iconData.toString("base64")) + expect(project.icon?.color).toBeUndefined() + }) + + test("should prefer favicon under .opencode", async () => { + const p = await loadProject() + const localData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x11, 0x22, 0x33, 0x44]) + await using tmp = await tmpdir({ git: true }) + await Filesystem.write(path.join(tmp.path, ".opencode", "assets", "favicon.png"), localData) + await Bun.write(path.join(tmp.path, "favicon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])) + + const { project } = await p.fromDirectory(tmp.path) + + expect(project.icon?.url).toContain(localData.toString("base64")) + expect(project.icon?.override).toContain(localData.toString("base64")) + expect(project.icon?.color).toBeUndefined() + }) + test("should discover favicon.png in root", async () => { const p = await loadProject() await using tmp = await tmpdir({ git: true }) @@ -281,6 +326,23 @@ describe("Project.update", () => { expect(fromDb?.icon?.color).toBe("#ff0000") }) + test("should map icon override to stored url", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const updated = await Project.update({ + projectID: project.id, + icon: { override: "https://example.com/override.png" }, + }) + + expect(updated.icon?.url).toBe("https://example.com/override.png") + expect(updated.icon?.override).toBe("https://example.com/override.png") + + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.url).toBe("https://example.com/override.png") + expect(fromDb?.icon?.override).toBe("https://example.com/override.png") + }) + test("should update commands", async () => { await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path)