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