Skip to content
Closed
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
3 changes: 3 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
21 changes: 17 additions & 4 deletions packages/app/src/settings/workspaces/project-model.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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())

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions packages/app/src/settings/workspaces/project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,39 @@ export const SettingsProjectGeneral: Component<{
/>
</SettingsRow>

<Show when={model.icons.data?.length}>
<SettingsRow
title={language.t("project.settings.icon.detected.title")}
description={language.t("project.settings.icon.detected.description")}
>
<div class="project-settings-colors" data-action="settings-project-icon-candidates">
<For each={model.icons.data}>
{(candidate) => {
const selected = () => model.store.iconOverride === candidate.url

return (
<button
type="button"
title={candidate.path}
aria-label={language.t("dialog.project.edit.icon.select", { path: candidate.path })}
aria-pressed={selected()}
class="project-settings-color"
classList={{ "project-settings-color--selected": selected() }}
onClick={() => model.selectIcon(candidate.url)}
>
<ProjectAvatar
fallback=""
src={candidate.url}
class="!size-5 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)
}}
</For>
</div>
</SettingsRow>
</Show>

<Show when={!model.store.iconOverride}>
<SettingsRow
title={language.t("dialog.project.edit.color")}
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,10 @@ export type ProjectListInput = { readonly archived?: boolean | undefined }
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = (input?: ProjectListInput) => Effect.Effect<ProjectListOutput, E>

export type ProjectIconsInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type ProjectIconsOutput = ReadonlyArray<Project.IconCandidate>
export type ProjectIconsOperation<E = never> = (input?: ProjectIconsInput) => Effect.Effect<ProjectIconsOutput, E>

export type ProjectIconInput = { readonly projectID: Project.ID; readonly v?: string | undefined }
export type ProjectIconOutput = globalThis.Uint8Array
export type ProjectIconOperation<E = never> = (input: ProjectIconInput) => Effect.Effect<ProjectIconOutput, E>
Expand All @@ -1731,6 +1735,7 @@ export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => E

export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly icons: ProjectIconsOperation<E>
readonly icon: ProjectIconOperation<E>
readonly update: ProjectUpdateOperation<E>
}
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ import type {
CredentialRemoveOutput,
ProjectListInput,
ProjectListOutput,
ProjectIconsInput,
ProjectIconsOutput,
ProjectIconInput,
ProjectIconOutput,
ProjectUpdateInput,
Expand Down Expand Up @@ -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<ProjectIconsOutput>()(
raw["project.icons"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)

const EndpointProjectIcon = (raw: RawClient["server.project"]) => (input: ProjectIconInput) =>
preserveEffect<ProjectIconOutput>()(
raw["project.icon"]({ params: { projectID: input["projectID"] }, query: { v: input["v"] } }).pipe(
Expand All @@ -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),
})
Expand Down
14 changes: 14 additions & 0 deletions packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ import type {
CredentialRemoveOutput,
ProjectListInput,
ProjectListOutput,
ProjectIconsInput,
ProjectIconsOutput,
ProjectIconInput,
ProjectIconOutput,
ProjectUpdateInput,
Expand Down Expand Up @@ -1516,6 +1518,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
icons: (input?: ProjectIconsInput, requestOptions?: RequestOptions) =>
request<ProjectIconsOutput>(
{
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<ProjectIconOutput>(
{
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -6285,6 +6287,12 @@ export type ProjectListInput = { readonly archived?: { readonly archived?: boole

export type ProjectListOutput = Array<Project>

export type ProjectIconsInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}

export type ProjectIconsOutput = Array<ProjectIconCandidate>

export type ProjectIconInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly v?: { readonly v?: string | undefined }["v"]
Expand Down
18 changes: 17 additions & 1 deletion packages/client/test/promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})

Expand Down Expand Up @@ -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 = {
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,6 +30,9 @@ export type Current = ProjectSchema.Current
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}

export const IconCandidate = ProjectSchema.IconCandidate
export type IconCandidate = ProjectSchema.IconCandidate

export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = ProjectSchema.UpdateInput

Expand Down Expand Up @@ -68,6 +71,7 @@ export interface ListInput {

export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly icons: (directory: AbsolutePath) => Effect.Effect<ReadonlyArray<IconCandidate>>
/** The discovered icon file of a Project, or nothing when it has none or uses a remote URL. */
readonly icon: (projectID: ID) => Effect.Effect<ProjectIcon.Image | undefined, NotFoundError>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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 })
}),
)

Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/project/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyArray<Candidate>> {
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")
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export type Current = typeof Current.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}

export const IconCandidate = Project.IconCandidate
export type IconCandidate = typeof IconCandidate.Type

export const UpdateInput = Project.UpdateInput
export type UpdateInput = typeof UpdateInput.Type

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/effect/layer-node/node-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/lib/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/test/project-icon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading