|
1 | 1 | 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" |
3 | 3 | import { Database } from "@opencode-ai/core/database/database" |
4 | 4 | import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" |
5 | 5 | 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" |
7 | 8 | import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" |
8 | 9 | import { Flag } from "@opencode-ai/core/flag/flag" |
9 | 10 | import { GlobalBus } from "@/bus/global" |
@@ -63,6 +64,8 @@ export const UpdateInput = Schema.Struct({ |
63 | 64 | name: Schema.optional(Schema.String), |
64 | 65 | icon: Schema.optional(Project.Icon), |
65 | 66 | commands: Schema.optional(Project.Commands), |
| 67 | + /** new absolute location for an existing worktree; relocates stored history */ |
| 68 | + worktree: Schema.optional(Schema.String), |
66 | 69 | }) |
67 | 70 | export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>> |
68 | 71 |
|
@@ -93,6 +96,10 @@ export interface Interface { |
93 | 96 | readonly list: () => Effect.Effect<Info[]> |
94 | 97 | readonly get: (id: ProjectV2.ID) => Effect.Effect<Info | undefined> |
95 | 98 | 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> |
96 | 103 | readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info> |
97 | 104 | readonly setInitialized: (id: ProjectV2.ID) => Effect.Effect<void> |
98 | 105 | readonly sandboxes: (id: ProjectV2.ID) => Effect.Effect<string[]> |
@@ -375,9 +382,35 @@ const layer = Layer.effect( |
375 | 382 | }) |
376 | 383 |
|
377 | 384 | 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 | + |
378 | 410 | const result = yield* db |
379 | 411 | .update(ProjectTable) |
380 | 412 | .set({ |
| 413 | + worktree, |
381 | 414 | name: input.name, |
382 | 415 | icon_url: input.icon?.url, |
383 | 416 | icon_url_override: input.icon?.override, |
@@ -406,6 +439,45 @@ const layer = Layer.effect( |
406 | 439 | return project |
407 | 440 | }) |
408 | 441 |
|
| 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 | + |
409 | 481 | const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) { |
410 | 482 | yield* db |
411 | 483 | .update(ProjectTable) |
@@ -486,6 +558,7 @@ const layer = Layer.effect( |
486 | 558 | list, |
487 | 559 | get, |
488 | 560 | update, |
| 561 | + remove, |
489 | 562 | initGit, |
490 | 563 | setInitialized, |
491 | 564 | sandboxes, |
|
0 commit comments