Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: z.string(),
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: row.id,
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading