Skip to content
5 changes: 2 additions & 3 deletions packages/app/src/components/dialog-edit-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!))
Expand Down
28 changes: 27 additions & 1 deletion packages/app/src/context/layout.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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")
})
})
72 changes: 11 additions & 61 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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,
},
}
Expand All @@ -424,60 +429,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
}

const roots = createMemo(() => {
const map = new Map<string, string>()
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<string>()
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()
Expand All @@ -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)
}
})

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/pages/layout/sidebar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
<Avatar
fallback={name()}
src={
props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
props.project.id === OPENCODE_PROJECT_ID
? "https://opencode.ai/favicon.svg"
: (props.project.icon?.url ?? props.project.icon?.override)
}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
Expand Down
134 changes: 115 additions & 19 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,75 @@ export namespace Project {
return path.resolve(cwd, name)
}

function sortPath(a: string, b: string) {
if (a.length !== b.length) return a.length - b.length
return a.localeCompare(b)
}

async function iconURL(file: string) {
const text = await Filesystem.readText(file)
.then((x) => 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: ProjectID.zod,
Expand Down Expand Up @@ -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: ProjectID.make(row.id),
Expand Down Expand Up @@ -231,10 +300,33 @@ export namespace Project {
},
}

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: {
Expand All @@ -250,7 +342,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,
Expand All @@ -262,7 +354,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,
Expand Down Expand Up @@ -297,24 +389,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
}
}

export function setInitialized(id: ProjectID) {
Expand Down Expand Up @@ -374,7 +470,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(),
Expand Down
Loading
Loading