mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-13 20:36:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7660d71e |
@@ -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,
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)" }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user