Skip to content

Commit bf6401d

Browse files
committed
feat(project): rename-with-relocation and cascade delete for projects
- project.update accepts worktree: when it points at a new location the stored history (sessions, messages, parts, todos, events) migrates via the relocation splicer, so Right-click > Edit > folder change in the Desktop keeps every session visible (#23248, #34737) - new DELETE /project/:id with mode=cascade|detach removes registration, directories and (cascade) all sessions + event aggregates; global project and unknown ids fail with ProjectNotFoundError - httpapi group + handler wiring for both operations GUI follow-up lands after `bun run codegen` regenerates sdk clients: Edit dialog gains a Folder field bound to update({worktree}), project context menu gains Delete... with a history-loss confirmation dialog.
1 parent 38c817f commit bf6401d

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

‎packages/opencode/src/project/project.ts‎

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2-
import { and, eq, sql } from "drizzle-orm"
2+
import { and, eq, inArray, sql } from "drizzle-orm"
33
import { Database } from "@opencode-ai/core/database/database"
44
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
55
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
6-
import { SessionTable } from "@opencode-ai/core/session/sql"
6+
import { MessageTable, PartTable, SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
7+
import { EventTable } from "@opencode-ai/core/event/sql"
78
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
89
import { Flag } from "@opencode-ai/core/flag/flag"
910
import { GlobalBus } from "@/bus/global"
@@ -63,6 +64,8 @@ export const UpdateInput = Schema.Struct({
6364
name: Schema.optional(Schema.String),
6465
icon: Schema.optional(Project.Icon),
6566
commands: Schema.optional(Project.Commands),
67+
/** new absolute location for an existing worktree; relocates stored history */
68+
worktree: Schema.optional(Schema.String),
6669
})
6770
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
6871

@@ -93,6 +96,10 @@ export interface Interface {
9396
readonly list: () => Effect.Effect<Info[]>
9497
readonly get: (id: ProjectV2.ID) => Effect.Effect<Info | undefined>
9598
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
99+
readonly remove: (input: {
100+
projectID: ProjectV2.ID
101+
mode: "cascade" | "detach"
102+
}) => Effect.Effect<void, NotFoundError>
96103
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
97104
readonly setInitialized: (id: ProjectV2.ID) => Effect.Effect<void>
98105
readonly sandboxes: (id: ProjectV2.ID) => Effect.Effect<string[]>
@@ -375,9 +382,35 @@ const layer = Layer.effect(
375382
})
376383

377384
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
385+
const current = yield* db
386+
.select()
387+
.from(ProjectTable)
388+
.where(eq(ProjectTable.id, input.projectID))
389+
.get()
390+
.pipe(Effect.orDie)
391+
if (!current) return yield* new NotFoundError({ projectID: input.projectID })
392+
393+
let worktree = current.worktree
394+
if (input.worktree && input.worktree !== current.worktree) {
395+
const next = AbsolutePath.make(input.worktree)
396+
if (!(yield* fs.exists(next).pipe(Effect.orDie))) {
397+
return yield* new NotFoundError({ projectID: input.projectID })
398+
}
399+
// history follows the folder: migrate every stored reference (#23248, #34737)
400+
yield* Relocation.relocateProjectData({ from: current.worktree, to: next }).pipe(
401+
Effect.catchAllCause((cause) =>
402+
Effect.logError("project history relocation failed", { cause, projectID: input.projectID }).pipe(
403+
Effect.asVoid,
404+
),
405+
),
406+
)
407+
worktree = next
408+
}
409+
378410
const result = yield* db
379411
.update(ProjectTable)
380412
.set({
413+
worktree,
381414
name: input.name,
382415
icon_url: input.icon?.url,
383416
icon_url_override: input.icon?.override,
@@ -406,6 +439,45 @@ const layer = Layer.effect(
406439
return project
407440
})
408441

442+
const remove = Effect.fn("Project.remove")(function* (input: {
443+
projectID: ProjectV2.ID
444+
mode: "cascade" | "detach"
445+
}) {
446+
if (input.projectID === ProjectV2.ID.make("global")) {
447+
return yield* new NotFoundError({ projectID: input.projectID })
448+
}
449+
const row = yield* db
450+
.select()
451+
.from(ProjectTable)
452+
.where(eq(ProjectTable.id, input.projectID))
453+
.get()
454+
.pipe(Effect.orDie)
455+
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
456+
457+
const sessionRows = yield* db
458+
.select({ id: SessionTable.id })
459+
.from(SessionTable)
460+
.where(eq(SessionTable.project_id, input.projectID))
461+
.all()
462+
.pipe(Effect.orDie)
463+
const ids = sessionRows.map((r) => r.id)
464+
465+
yield* db.transaction((tx) =>
466+
Effect.gen(function* () {
467+
// messages/parts/todos cascade from session rows; events do not
468+
for (let i = 0; i < ids.length; i += 400) {
469+
const chunk = ids.slice(i, i + 400)
470+
yield* tx.delete(EventTable).where(inArray(EventTable.aggregate_id, chunk)).run().pipe(Effect.orDie)
471+
}
472+
if (input.mode === "cascade") {
473+
yield* tx.delete(SessionTable).where(eq(SessionTable.project_id, input.projectID)).run().pipe(Effect.orDie)
474+
}
475+
yield* tx.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, input.projectID)).run().pipe(Effect.orDie)
476+
yield* tx.delete(ProjectTable).where(eq(ProjectTable.id, input.projectID)).run().pipe(Effect.orDie)
477+
}),
478+
)
479+
})
480+
409481
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) {
410482
yield* db
411483
.update(ProjectTable)
@@ -486,6 +558,7 @@ const layer = Layer.effect(
486558
list,
487559
get,
488560
update,
561+
remove,
489562
initGit,
490563
setInitialized,
491564
sandboxes,

‎packages/opencode/src/server/routes/instance/httpapi/groups/project.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { described } from "./metadata"
1111
const root = "/project"
1212
const UpdatePayload = Schema.Struct({
1313
name: Schema.optional(Schema.String),
14+
worktree: Schema.optional(Schema.String),
1415
icon: Schema.optional(Project.Info.fields.icon),
1516
commands: Schema.optional(Project.Info.fields.commands),
1617
})
@@ -62,6 +63,19 @@ export const ProjectApi = HttpApi.make("project")
6263
description: "Update project properties such as name, icon, and commands.",
6364
}),
6465
),
66+
HttpApiEndpoint.del("remove", `${root}/:projectID`, {
67+
params: { projectID: ProjectV2.ID },
68+
query: WorkspaceRoutingQuery,
69+
payload: Schema.Struct({ mode: Schema.optional(Schema.Literals(["cascade", "detach"])) }),
70+
success: described(Schema.Void, "Deletion result"),
71+
error: [HttpApiError.BadRequest, ProjectNotFoundError],
72+
}).annotateMerge(
73+
OpenApi.annotations({
74+
identifier: "project.remove",
75+
summary: "Delete a project",
76+
description: "Remove a registered project. mode=cascade also deletes every session and its history.",
77+
}),
78+
),
6579
HttpApiEndpoint.get("directories", `${root}/:projectID/directories`, {
6680
params: { projectID: ProjectV2.ID },
6781
query: WorkspaceRoutingQuery,

‎packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,26 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
5353
project.directories({ projectID: ctx.params.projectID }),
5454
)
5555

56+
const remove = Effect.fn("ProjectHttpApi.remove")(function* (ctx: {
57+
params: { projectID: ProjectV2.ID }
58+
payload: { mode?: "cascade" | "detach" }
59+
}) {
60+
return yield* svc
61+
.remove({ projectID: ctx.params.projectID, mode: ctx.payload.mode ?? "cascade" })
62+
.pipe(
63+
Effect.catchTag("Project.NotFoundError", (error) =>
64+
Effect.fail(new ProjectNotFoundError({ projectID: error.projectID })),
65+
),
66+
Effect.asVoid,
67+
)
68+
})
69+
5670
return handlers
5771
.handle("list", list)
5872
.handle("current", current)
5973
.handle("initGit", initGit)
6074
.handle("update", update)
6175
.handle("directories", directories)
76+
.handle("remove", remove)
6277
}),
6378
)

0 commit comments

Comments
 (0)