Compare commits

...
Author SHA1 Message Date
Brendan Allan 4a7660d71e feat(app): discover and select project favicons 2026-08-26 17:43:35 +08:00
16 changed files with 189 additions and 6 deletions
+1
View File
@@ -457,6 +457,7 @@ export const dict = {
"dialog.project.edit.icon.alt": "Project icon",
"dialog.project.edit.icon.hint": "Click or drag an image",
"dialog.project.edit.icon.recommended": "Recommended: 128x128px",
"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": "Workspace startup script",
@@ -143,6 +143,32 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
<Show when={model.icons.data?.length}>
<div class="-ml-1 flex flex-wrap gap-1.5">
<For each={model.icons.data}>
{(candidate) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.select", { path: candidate.path })}
aria-pressed={model.store.iconOverride === candidate.url}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
model.store.iconOverride === candidate.url,
}}
onClick={() => model.setStore("iconOverride", candidate.url)}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={candidate.url}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</Show>
</div>
<Show when={!model.store.iconOverride}>
@@ -1,6 +1,6 @@
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { createQuery, useMutation } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
@@ -11,6 +11,11 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const icons = createQuery(() => ({
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())
const [store, setStore] = createStore({
@@ -100,6 +105,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
setStore,
folderName,
defaultName,
icons,
save,
submit,
drop,
+7
View File
@@ -1360,6 +1360,12 @@ export interface CredentialApi<E = never> {
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
export type ProjectIconsInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type ProjectIconsOutput = ReadonlyArray<Project.IconCandidate>
export type ProjectIconsOperation<E = never> = (input?: ProjectIconsInput) => Effect.Effect<ProjectIconsOutput, E>
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly name?: string | undefined
@@ -1377,6 +1383,7 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly icons: ProjectIconsOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
}
@@ -143,6 +143,8 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectIconsInput,
ProjectIconsOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
@@ -962,6 +964,11 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointProjectIcons = (raw: RawClient["server.project"]) => (input?: ProjectIconsInput) =>
preserveEffect<ProjectIconsOutput>()(
raw["project.icons"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
@@ -977,6 +984,7 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
icons: EndpointProjectIcons(raw),
update: EndpointProjectUpdate(raw),
current: EndpointProjectCurrent(raw),
})
@@ -137,6 +137,8 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectIconsInput,
ProjectIconsOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
@@ -1303,6 +1305,18 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
icons: (input?: ProjectIconsInput, requestOptions?: RequestOptions) =>
request<ProjectIconsOutput>(
{
method: "GET",
path: `/api/project/icons`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
request<ProjectUpdateOutput>(
{
@@ -301,6 +301,8 @@ export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number; initialized?: number }
export type ProjectIconCandidate = { path: string; url: string }
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type FormMetadata = { [x: string]: JsonValue }
@@ -4406,6 +4408,14 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project>
export type ProjectIconsInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectIconsOutput = Array<ProjectIconCandidate>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly name?: {
+17 -1
View File
@@ -51,7 +51,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.project)).toEqual(["list", "icons", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -82,6 +82,22 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
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) => {
request = input instanceof Request ? input : new Request(input)
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 = {
+21 -2
View File
@@ -4,10 +4,11 @@ import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { and, asc, desc, eq, gte, isNull, lte } 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-ai/schema/worktree"
import type { IconCandidate } from "@opencode-ai/schema/project"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
@@ -54,6 +55,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly icons: (directory: AbsolutePath) => Effect.Effect<ReadonlyArray<IconCandidate>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
@@ -197,6 +199,23 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const icons = Effect.fn("Project.icons")(function* (directory: AbsolutePath) {
const files = yield* fs
.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { cwd: directory, include: "file" })
.pipe(Effect.orDie)
return yield* Effect.forEach(
files.toSorted((a, b) => a.length - b.length || a.localeCompare(b)),
(file) =>
fs.readFile(path.join(directory, file)).pipe(
Effect.orDie,
Effect.map((content) => ({
path: RelativePath.make(file.replaceAll("\\", "/")),
url: `data:${FSUtil.mimeType(file)};base64,${Buffer.from(content).toString("base64")}`,
})),
),
)
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const row = yield* db
.update(ProjectTable)
@@ -342,7 +361,7 @@ const layer = Layer.effect(
})
})
return Service.of({ list, update, resolve })
return Service.of({ list, icons, update, resolve })
}),
)
@@ -78,6 +78,7 @@ describe("node build", () => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
icons: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
+1
View File
@@ -5,6 +5,7 @@ export const globalProjectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
icons: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
}),
+1
View File
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
icons: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: () =>
Effect.succeed({
+48 -1
View File
@@ -10,7 +10,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { Project } from "@opencode-ai/core/project"
import { ProjectSchema } from "@opencode-ai/core/project/schema"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -69,6 +69,53 @@ describe("Project.list", () => {
)
})
describe("Project.icons", () => {
it.live("discovers supported favicons ordered by relative path length and name", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "a"))
await fs.mkdir(path.join(tmp.path, "b"))
await fs.mkdir(path.join(tmp.path, "nested", "app"), { recursive: true })
await Bun.write(path.join(tmp.path, "favicon.ico"), "ico")
await Bun.write(path.join(tmp.path, "favicon.png"), "png")
await Bun.write(path.join(tmp.path, "a", "favicon.svg"), "<svg />")
await Bun.write(path.join(tmp.path, "b", "favicon.jpg"), "jpg")
await Bun.write(path.join(tmp.path, "nested", "app", "favicon.jpeg"), "jpeg")
await Bun.write(path.join(tmp.path, "nested", "app", "favicon.webp"), "webp")
await Bun.write(path.join(tmp.path, "nested", "app", "favicon.gif"), "gif")
await Bun.write(path.join(tmp.path, "nested", "app", "icon.png"), "icon")
})
const project = yield* Project.Service
expect(yield* project.icons(abs(tmp.path))).toEqual([
{ path: RelativePath.make("favicon.ico"), url: "data:image/vnd.microsoft.icon;base64,aWNv" },
{ path: RelativePath.make("favicon.png"), url: "data:image/png;base64,cG5n" },
{ path: RelativePath.make("a/favicon.svg"), url: "data:image/svg+xml;base64,PHN2ZyAvPg==" },
{ path: RelativePath.make("b/favicon.jpg"), url: "data:image/jpeg;base64,anBn" },
{ path: RelativePath.make("nested/app/favicon.jpeg"), url: "data:image/jpeg;base64,anBlZw==" },
{ path: RelativePath.make("nested/app/favicon.webp"), url: "data:image/webp;base64,d2VicA==" },
])
}),
)
it.live("returns no candidates when the worktree has no favicons", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "icon.png"), "icon"))
const project = yield* Project.Service
expect(yield* project.icons(abs(tmp.path))).toEqual([])
}),
)
})
describe("Project.update", () => {
it.effect("updates and clears project metadata", () =>
Effect.gen(function* () {
+14
View File
@@ -19,6 +19,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: "v2.project.icons",
summary: "List project icons",
description: "Discover favicon candidates in the requested project worktree.",
}),
),
)
.add(
HttpApiEndpoint.patch("project.update", `${root}/:projectID`, {
params: { projectID: Project.ID },
+6 -1
View File
@@ -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
@@ -21,6 +21,11 @@ export const Icon = Schema.Struct({
color: optional(Schema.String),
}).annotate({ identifier: "Project.Icon" })
export interface Icon extends Schema.Schema.Type<typeof Icon> {}
export const IconCandidate = Schema.Struct({
path: RelativePath,
url: Schema.String,
}).annotate({ identifier: "Project.IconCandidate" })
export interface IconCandidate extends Schema.Schema.Type<typeof IconCandidate> {}
export const Commands = Schema.Struct({
start: optional(
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
+7
View File
@@ -8,6 +8,13 @@ import { ProjectNotFoundError } from "@opencode-ai/protocol/errors"
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
handlers
.handle("project.list", () => Project.Service.use((project) => project.list()))
.handle("project.icons", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const project = yield* Project.Service
return yield* project.icons(location.project.directory)
}),
)
.handle("project.update", (ctx) =>
Project.Service.use((project) =>
project.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(