diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index 3c0da4d95a3b..807a6abc17bf 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -417,6 +417,7 @@ export const dict = {
"dialog.project.edit.title": "Edit project",
"dialog.project.edit.icon": "Icon",
"dialog.project.edit.icon.alt": "Project icon",
+ "dialog.project.edit.icon.select": "Select {{path}} as project icon",
"dialog.project.edit.color": "Color",
"dialog.project.edit.color.select": "Select {{color}} color",
"dialog.project.edit.worktree.startup": "Worktree startup script",
@@ -852,6 +853,8 @@ export const dict = {
"project.settings.name.title": "Project name",
"project.settings.name.description": "The name shown for this project throughout OpenCode",
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
+ "project.settings.icon.detected.title": "Detected icons",
+ "project.settings.icon.detected.description": "Images found in this project.",
"project.settings.color.description": "Used for the project icon when no custom image is set",
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
diff --git a/packages/app/src/settings/workspaces/project-model.ts b/packages/app/src/settings/workspaces/project-model.ts
index 5b1ad4d72f8c..40b5f4df00c9 100644
--- a/packages/app/src/settings/workspaces/project-model.ts
+++ b/packages/app/src/settings/workspaces/project-model.ts
@@ -1,5 +1,6 @@
import { getFilename } from "@opencode/util/path"
import type { ProjectUpdateInput } from "@opencode/client/promise"
+import { useQuery } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
@@ -14,6 +15,13 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const language = useLanguage()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
+
+ const icons = useQuery(() => ({
+ queryKey: [serverCtx().sdk.scope, props.project.worktree, "project-icons"] as const,
+ queryFn: ({ signal }) =>
+ serverCtx().sdk.api.project.icons({ location: { directory: props.project.worktree } }, { signal }),
+ }))
+
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
@@ -51,7 +59,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
serverCtx().sync.project.meta(props.project.worktree, patch)
})
.then(complete)
- .catch((error: unknown) => {
+ .catch((error) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
@@ -91,10 +99,10 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
- reader.onload = (event) => {
- const result = event.target?.result
+ reader.onload = () => {
+ const result = reader.result
- if (typeof result !== "string") return
+ if (!result || result instanceof ArrayBuffer) return
setStore("iconOverride", result)
setStore("iconHover", false)
saveIcon(store.color, result)
@@ -108,8 +116,13 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
setStore,
folderName,
defaultName,
+ icons,
saveName,
saveStartup,
+ selectIcon(url: string) {
+ setStore("iconOverride", url)
+ saveIcon(store.color, url)
+ },
setColor(value: string | undefined) {
setStore("color", value)
saveIcon(value, store.iconOverride)
diff --git a/packages/app/src/settings/workspaces/project.tsx b/packages/app/src/settings/workspaces/project.tsx
index 8a16dc55f3e0..8d2eafd12cfb 100644
--- a/packages/app/src/settings/workspaces/project.tsx
+++ b/packages/app/src/settings/workspaces/project.tsx
@@ -124,6 +124,39 @@ export const SettingsProjectGeneral: Component<{
/>
+
+
+
+
+ {(candidate) => {
+ const selected = () => model.store.iconOverride === candidate.url
+
+ return (
+
+ )
+ }}
+
+
+
+
+
export type ProjectListOperation = (input?: ProjectListInput) => Effect.Effect
+export type ProjectIconsInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
+export type ProjectIconsOutput = ReadonlyArray
+export type ProjectIconsOperation = (input?: ProjectIconsInput) => Effect.Effect
+
export type ProjectIconInput = { readonly projectID: Project.ID; readonly v?: string | undefined }
export type ProjectIconOutput = globalThis.Uint8Array
export type ProjectIconOperation = (input: ProjectIconInput) => Effect.Effect
@@ -1731,6 +1735,7 @@ export type ProjectUpdateOperation = (input: ProjectUpdateInput) => E
export interface ProjectApi {
readonly list: ProjectListOperation
+ readonly icons: ProjectIconsOperation
readonly icon: ProjectIconOperation
readonly update: ProjectUpdateOperation
}
diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts
index 88f1e09f6fb6..686e6d563d98 100644
--- a/packages/client/src/effect/generated/client.ts
+++ b/packages/client/src/effect/generated/client.ts
@@ -168,6 +168,8 @@ import type {
CredentialRemoveOutput,
ProjectListInput,
ProjectListOutput,
+ ProjectIconsInput,
+ ProjectIconsOutput,
ProjectIconInput,
ProjectIconOutput,
ProjectUpdateInput,
@@ -1136,6 +1138,11 @@ const EndpointProjectList = (raw: RawClient["server.project"]) => (input?: Proje
raw["project.list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapClientError)),
)
+const EndpointProjectIcons = (raw: RawClient["server.project"]) => (input?: ProjectIconsInput) =>
+ preserveEffect()(
+ raw["project.icons"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
+ )
+
const EndpointProjectIcon = (raw: RawClient["server.project"]) => (input: ProjectIconInput) =>
preserveEffect()(
raw["project.icon"]({ params: { projectID: input["projectID"] }, query: { v: input["v"] } }).pipe(
@@ -1153,6 +1160,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
+ icons: EndpointProjectIcons(raw),
icon: EndpointProjectIcon(raw),
update: EndpointProjectUpdate(raw),
})
diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts
index ef0123343ad8..bf77543a8a1d 100644
--- a/packages/client/src/promise/generated/client.ts
+++ b/packages/client/src/promise/generated/client.ts
@@ -162,6 +162,8 @@ import type {
CredentialRemoveOutput,
ProjectListInput,
ProjectListOutput,
+ ProjectIconsInput,
+ ProjectIconsOutput,
ProjectIconInput,
ProjectIconOutput,
ProjectUpdateInput,
@@ -1516,6 +1518,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
+ icons: (input?: ProjectIconsInput, requestOptions?: RequestOptions) =>
+ request(
+ {
+ method: "GET",
+ path: `/api/project/icons`,
+ query: { location: input?.["location"] },
+ successStatus: 200,
+ declaredStatuses: [400, 401, 404],
+ empty: false,
+ },
+ requestOptions,
+ ),
icon: (input: ProjectIconInput, requestOptions?: RequestOptions) =>
request(
{
diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts
index 74d657b1434b..b82ac67112af 100644
--- a/packages/client/src/promise/generated/types.ts
+++ b/packages/client/src/promise/generated/types.ts
@@ -406,6 +406,8 @@ export type SessionMessageCompactionRunning = {
recent: string
}
+export type ProjectIconCandidate = { path: RelativePath; url: string }
+
export type FileSystemEntry = { path: RelativePath; type: "file" | "directory" }
export type SessionRevert = {
@@ -6285,6 +6287,12 @@ export type ProjectListInput = { readonly archived?: { readonly archived?: boole
export type ProjectListOutput = Array
+export type ProjectIconsInput = {
+ readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
+}
+
+export type ProjectIconsOutput = Array
+
export type ProjectIconInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly v?: { readonly v?: string | undefined }["v"]
diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts
index c786618244db..bcfc839dec60 100644
--- a/packages/client/test/promise.test.ts
+++ b/packages/client/test/promise.test.ts
@@ -54,7 +54,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
expect(client.experimental.persistentPty.read).toBeFunction()
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
- expect(Object.keys(client.project)).toEqual(["list", "icon", "update"])
+ expect(Object.keys(client.project)).toEqual(["list", "icons", "icon", "update"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -218,6 +218,22 @@ test("client errors keep the reason and describe the failure in the message", as
)
})
+test("project.icons uses the location-scoped project contract", async () => {
+ let request: Request | undefined
+ const icons = [{ path: "public/favicon.svg", url: "data:image/svg+xml;base64,PHN2ZyAvPg==" }]
+ const client = OpenCode.make({
+ baseUrl: "http://localhost:3000",
+ fetch: async (input, init) => {
+ request = input instanceof Request ? input : new Request(input, init)
+ return Response.json(icons)
+ },
+ })
+
+ expect(await client.project.icons({ location: { directory: "/tmp/project" } })).toEqual(icons)
+ expect(request?.method).toBe("GET")
+ expect(request?.url).toBe("http://localhost:3000/api/project/icons?location%5Bdirectory%5D=%2Ftmp%2Fproject")
+})
+
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts
index 1e1e3ca7e993..d42db48932b1 100644
--- a/packages/core/src/project.ts
+++ b/packages/core/src/project.ts
@@ -4,7 +4,7 @@ import { Context, Duration, Effect, Layer, Schedule, Schema } from "effect"
import { ChildProcess } from "effect/process"
import { and, asc, desc, eq, gte, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm"
import path from "path"
-import { AbsolutePath } from "./schema.js"
+import { AbsolutePath, RelativePath } from "./schema.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Worktree } from "@opencode/schema/worktree"
@@ -30,6 +30,9 @@ export type Current = ProjectSchema.Current
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type {}
+export const IconCandidate = ProjectSchema.IconCandidate
+export type IconCandidate = ProjectSchema.IconCandidate
+
export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = ProjectSchema.UpdateInput
@@ -68,6 +71,7 @@ export interface ListInput {
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect>
+ readonly icons: (directory: AbsolutePath) => Effect.Effect>
/** The discovered icon file of a Project, or nothing when it has none or uses a remote URL. */
readonly icon: (projectID: ID) => Effect.Effect
readonly update: (input: UpdateInput) => Effect.Effect
@@ -242,6 +246,18 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
+ const icons = Effect.fn("Project.icons")(function* (directory: AbsolutePath) {
+ const candidates = yield* ProjectIcon.list(fs, directory)
+ return candidates.map((item) =>
+ "url" in item
+ ? { path: RelativePath.make("opencode.json"), url: item.url }
+ : {
+ path: RelativePath.make(item.path),
+ url: `data:${item.type};base64,${Buffer.from(item.bytes).toString("base64")}`,
+ },
+ )
+ })
+
const icon = Effect.fn("Project.icon")(function* (projectID: ID) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ projectID })
@@ -480,7 +496,7 @@ const layer = Layer.effect(
)
})
- return Service.of({ list, icon, update, activate, resolve, sweep })
+ return Service.of({ list, icons, icon, update, activate, resolve, sweep })
}),
)
diff --git a/packages/core/src/project/icon.ts b/packages/core/src/project/icon.ts
index 909692a779f4..f1a0df50fea7 100644
--- a/packages/core/src/project/icon.ts
+++ b/packages/core/src/project/icon.ts
@@ -32,11 +32,24 @@ const CANDIDATES = [
const decodeConfig = Schema.decodeUnknownOption(Schema.Struct({ icon: Schema.String }))
export const discover = Effect.fn("ProjectIcon.discover")(function* (fs: FSUtil.Interface, root: AbsolutePath) {
+ return (yield* list(fs, root, 1))[0]
+})
+
+export const list = Effect.fn("ProjectIcon.list")(function* (
+ fs: FSUtil.Interface,
+ root: AbsolutePath,
+ limit = Infinity,
+): Effect.fn.Return> {
+ const found: Candidate[] = []
for (const name of ["opencode.jsonc", "opencode.json"]) {
const text = yield* fs.readFileString(path.join(root, name)).pipe(Effect.orElseSucceed(() => undefined))
const val = text !== undefined ? Option.getOrUndefined(decodeConfig(parse(text)))?.icon.trim() : undefined
const hit = val ? (/^https?:/i.test(val) ? { url: val } : yield* read(fs, root, val)) : undefined
- if (hit) return hit
+ if (hit) {
+ found.push(hit)
+ if (found.length >= limit) return found
+ break
+ }
}
for (const candidate of CANDIDATES) {
const entries = candidate.endsWith(".appiconset")
@@ -47,8 +60,12 @@ export const discover = Effect.fn("ProjectIcon.discover")(function* (fs: FSUtil.
.toSorted()
const picked = files ? (files.find((n) => /128|256/.test(n)) ?? files[0]) : undefined
const image = entries ? (picked ? yield* read(fs, root, `${candidate}/${picked}`) : undefined) : yield* read(fs, root, candidate)
- if (image) return image
+ if (image && !found.some((prev) => "path" in prev && prev.path === image.path)) {
+ found.push(image)
+ if (found.length >= limit) return found
+ }
}
+ return found
})
export const read = Effect.fn("ProjectIcon.read")(function* (fs: FSUtil.Interface, root: AbsolutePath, relative: string) {
diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts
index 1c3802c4ac6d..fd4712b62cce 100644
--- a/packages/core/src/project/schema.ts
+++ b/packages/core/src/project/schema.ts
@@ -13,6 +13,9 @@ export type Current = typeof Current.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type {}
+export const IconCandidate = Project.IconCandidate
+export type IconCandidate = typeof IconCandidate.Type
+
export const UpdateInput = Project.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts
index f9211d3fb3dc..9aa3007e1d1c 100644
--- a/packages/core/test/effect/layer-node/node-build.test.ts
+++ b/packages/core/test/effect/layer-node/node-build.test.ts
@@ -99,6 +99,7 @@ describe("node build", () => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
+ icons: () => Effect.succeed([]),
icon: () => Effect.succeed(undefined),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
diff --git a/packages/core/test/lib/project.ts b/packages/core/test/lib/project.ts
index 2d6f7eb899c9..e603276d1bb9 100644
--- a/packages/core/test/lib/project.ts
+++ b/packages/core/test/lib/project.ts
@@ -12,6 +12,7 @@ export const globalProjectNode = makeGlobalNode({
const database = yield* Database.Service
return Project.Service.of({
list: () => Effect.succeed([]),
+ icons: () => Effect.succeed([]),
icon: () => Effect.succeed(undefined),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts
index 6e7fcdbce099..7f803f2a7a98 100644
--- a/packages/core/test/location.test.ts
+++ b/packages/core/test/location.test.ts
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
+ icons: () => Effect.succeed([]),
icon: () => Effect.succeed(undefined),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
diff --git a/packages/core/test/project-icon.test.ts b/packages/core/test/project-icon.test.ts
index 644747573075..6b75d239c946 100644
--- a/packages/core/test/project-icon.test.ts
+++ b/packages/core/test/project-icon.test.ts
@@ -11,7 +11,7 @@ import { Project } from "@opencode/core/project"
import { ProjectIcon } from "@opencode/core/project/icon"
import { ProjectSchema } from "@opencode/core/project/schema"
import { ProjectTable } from "@opencode/core/project/sql"
-import { AbsolutePath } from "@opencode/core/schema"
+import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { FSUtil } from "@opencode/util/fs-util"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -96,6 +96,10 @@ describe("ProjectIcon", () => {
expect(firstUrl).toMatch(new RegExp(`^/api/project/${resolved.id}/icon\\?v=[0-9a-f]{16}$`))
expect(events.at(-1)?.icon?.url).toBe(firstUrl)
expect(yield* readText()).toBe(SVG)
+ expect((yield* project.icons(root)).map((item) => item.path)).toEqual([
+ RelativePath.make("public/favicon.svg"),
+ RelativePath.make("assets/logo.svg"),
+ ])
yield* Effect.promise(() => writeFile(root, "public/favicon.svg", SVG_ALT))
yield* project.sweep()
diff --git a/packages/protocol/src/groups/project.ts b/packages/protocol/src/groups/project.ts
index c7c28647003d..d513518ce72d 100644
--- a/packages/protocol/src/groups/project.ts
+++ b/packages/protocol/src/groups/project.ts
@@ -2,6 +2,7 @@ import { Project } from "@opencode/schema/project"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api"
import { FileNotFoundError, ProjectNotFoundError } from "../errors.js"
+import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/project"
const UpdatePayload = Schema.Struct(Struct.omit(Project.UpdateInput.fields, ["projectID"]))
@@ -25,6 +26,20 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
}),
),
)
+ .add(
+ HttpApiEndpoint.get("project.icons", `${root}/icons`, {
+ query: LocationQuery,
+ success: Schema.Array(Project.IconCandidate),
+ })
+ .annotateMerge(locationQueryOpenApi)
+ .annotateMerge(
+ OpenApi.annotations({
+ identifier: "project.icons",
+ summary: "List project icons",
+ description: "Discover icon candidates in the requested project worktree.",
+ }),
+ ),
+ )
.add(
HttpApiEndpoint.get("project.icon", `${root}/:projectID/icon`, {
params: { projectID: Project.ID },
diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts
index 1599cb54c760..8418a51fd6d7 100644
--- a/packages/schema/src/project.ts
+++ b/packages/schema/src/project.ts
@@ -2,7 +2,7 @@ export * as Project from "./project.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
-import { AbsolutePath, NonNegativeInt, optional } from "./schema.js"
+import { AbsolutePath, NonNegativeInt, optional, RelativePath } from "./schema.js"
import { ProjectID } from "./project-id.js"
export const ID = ProjectID
@@ -23,6 +23,11 @@ export const Icon = Schema.Struct({
color: optional(Schema.String),
}).annotate({ identifier: "Project.Icon" })
export interface Icon extends Schema.Schema.Type {}
+export const IconCandidate = Schema.Struct({
+ path: RelativePath,
+ url: Schema.String,
+}).annotate({ identifier: "Project.IconCandidate" })
+export interface IconCandidate extends Schema.Schema.Type {}
export const Commands = Schema.Struct({
start: optional(
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts
index 45192aef1e29..547436d5bb5c 100644
--- a/packages/server/src/handlers/project.ts
+++ b/packages/server/src/handlers/project.ts
@@ -1,3 +1,4 @@
+import { Location } from "@opencode/core/location"
import { Project } from "@opencode/core/project"
import { Effect } from "effect"
import { HttpServerResponse } from "effect/http"
@@ -11,6 +12,13 @@ const notFound = (projectID: Project.ID) =>
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
handlers
.handle("project.list", (ctx) => Project.Service.use((project) => project.list(ctx.query)))
+ .handle("project.icons", () =>
+ Effect.gen(function* () {
+ const location = yield* Location.Service
+ const project = yield* Project.Service
+ return yield* project.icons(location.project.directory)
+ }),
+ )
.handleRaw("project.icon", (ctx) =>
Effect.gen(function* () {
const project = yield* Project.Service