feat(core): project copying and tracking directories (#30139)

This commit is contained in:
James Long
2026-06-02 23:26:10 -04:00
committed by GitHub
parent 7a89182ec9
commit fe8e3ef736
28 changed files with 3638 additions and 10 deletions
@@ -12,6 +12,7 @@ import { InstanceApi } from "./groups/instance"
import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
import { ProjectCopyApi } from "./groups/project-copy"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
@@ -52,6 +53,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(ProjectApi)
.addHttpApi(ProjectCopyApi)
.addHttpApi(PtyApi)
.addHttpApi(QuestionApi)
.addHttpApi(PermissionApi)
@@ -0,0 +1,67 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/project/:projectID/copy"
export const CreatePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
directory: ProjectCopy.CreateInput.fields.directory,
})
export const RemovePayload = Schema.Struct({
directory: ProjectCopy.RemoveInput.fields.directory,
})
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
HttpApiGroup.make("projectCopy")
.add(
HttpApiEndpoint.post("create", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: CreatePayload,
success: described(ProjectCopy.Copy, "Project copy created"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.create",
summary: "Create project copy",
description: "Create a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.delete("remove", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: RemovePayload,
success: described(HttpApiSchema.NoContent, "Project copy removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.remove",
summary: "Remove project copy",
description: "Remove a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.post("refresh", `${root}/refresh`, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: HttpApiSchema.NoContent,
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.refresh",
summary: "Refresh project copies",
description: "Discover local project copies using one or all configured strategies.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
@@ -62,6 +62,17 @@ export const ProjectApi = HttpApi.make("project")
description: "Update project properties such as name, icon, and commands.",
}),
),
HttpApiEndpoint.get("directories", `${root}/:projectID/directories`, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
success: described(ProjectV2.Directories, "Project directories"),
}).annotateMerge(
OpenApi.annotations({
identifier: "project.directories",
summary: "List project directories",
description: "List known local absolute directories for a project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -0,0 +1,53 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstanceState } from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, RemovePayload } from "../groups/project-copy"
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
}
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
Effect.gen(function* () {
const service = yield* ProjectCopy.Service
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type
}) {
return yield* badRequest(
service.create({
...ctx.payload,
projectID: ctx.params.projectID,
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
}),
)
})
const remove = Effect.fn("ProjectCopyHttpApi.remove")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof RemovePayload.Type
}) {
yield* badRequest(
service.remove({
...ctx.payload,
projectID: ctx.params.projectID,
}),
)
})
const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) {
yield* badRequest(
service.refresh({
projectID: ctx.params.projectID,
}),
)
})
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
}),
)
@@ -10,6 +10,7 @@ import { markInstanceForReload } from "../lifecycle"
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const project = yield* ProjectV2.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
@@ -48,6 +49,15 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
)
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
const directories = Effect.fn("ProjectHttpApi.directories")((ctx: { params: { projectID: ProjectV2.ID } }) =>
project.directories({ projectID: ctx.params.projectID }),
)
return handlers
.handle("list", list)
.handle("current", current)
.handle("initGit", initGit)
.handle("update", update)
.handle("directories", directories)
}),
)
@@ -26,6 +26,8 @@ import { Installation } from "@/installation"
import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
@@ -74,6 +76,7 @@ import { instanceHandlers } from "./handlers/instance"
import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { projectCopyHandlers } from "./handlers/project-copy"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
@@ -135,6 +138,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
instanceHandlers,
mcpHandlers,
projectHandlers,
projectCopyHandlers,
ptyHandlers,
questionHandlers,
permissionHandlers,
@@ -204,6 +208,8 @@ export function createRoutes(
Permission.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,