diff --git a/packages/core/schema.json b/packages/core/schema.json index 954b3b94f..7179f70e8 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,10 @@ { "version": "7", "dialect": "sqlite", - "id": "abd2f920-b822-49af-b8a7-2e48367d424f", - "prevIds": ["f25f9126-c7dc-4882-9ff4-af27e11d2da1"], + "id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db", + "prevIds": [ + "abd2f920-b822-49af-b8a7-2e48367d424f" + ], "ddl": [ { "name": "workspace", @@ -622,7 +624,7 @@ }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, @@ -630,6 +632,16 @@ "entityType": "columns", "table": "project_directory" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "strategy", + "entityType": "columns", + "table": "project_directory" + }, { "type": "integer", "notNull": true, @@ -1501,9 +1513,13 @@ "table": "session_share" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1512,9 +1528,13 @@ "table": "workspace" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1523,9 +1543,13 @@ "table": "account_state" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1534,9 +1558,13 @@ "table": "event" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1545,9 +1573,13 @@ "table": "permission" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1556,9 +1588,13 @@ "table": "project_directory" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1567,9 +1603,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1578,9 +1618,13 @@ "table": "part" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1589,9 +1633,13 @@ "table": "session_context_epoch" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1600,9 +1648,13 @@ "table": "session_input" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1611,9 +1663,13 @@ "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1622,9 +1678,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1633,9 +1693,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1644,133 +1708,174 @@ "table": "session_share" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["project_id", "directory"], + "columns": [ + "project_id", + "directory" + ], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["name"], + "columns": [ + "name" + ], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", @@ -2088,4 +2193,4 @@ } ], "renames": [] -} +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 44a354922..1e915bb3c 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -36,5 +36,6 @@ export const migrations = ( import("./migration/20260605042240_add_context_epoch_agent"), import("./migration/20260611035744_credential"), import("./migration/20260611192811_lush_chimera"), + import("./migration/20260612174303_project_dir_strategy"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260612174303_project_dir_strategy.ts b/packages/core/src/database/migration/20260612174303_project_dir_strategy.ts new file mode 100644 index 000000000..10cd31332 --- /dev/null +++ b/packages/core/src/database/migration/20260612174303_project_dir_strategy.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260612174303_project_dir_strategy", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`project_directory\` ADD \`strategy\` text;`) + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + CREATE TABLE \`__new_project_directory\` ( + \`project_id\` text NOT NULL, + \`directory\` text NOT NULL, + \`type\` text, + \`strategy\` text, + \`time_created\` integer NOT NULL, + CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`), + CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`INSERT INTO \`__new_project_directory\`(\`project_id\`, \`directory\`, \`type\`, \`time_created\`) SELECT \`project_id\`, \`directory\`, \`type\`, \`time_created\` FROM \`project_directory\`;`) + yield* tx.run(`DROP TABLE \`project_directory\`;`) + yield* tx.run(`ALTER TABLE \`__new_project_directory\` RENAME TO \`project_directory\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 7330e8b7e..5190e5838 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -101,7 +101,8 @@ export default { CREATE TABLE \`project_directory\` ( \`project_id\` text NOT NULL, \`directory\` text NOT NULL, - \`type\` text NOT NULL, + \`type\` text, + \`strategy\` text, \`time_created\` integer NOT NULL, CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`), CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE @@ -240,32 +241,16 @@ export default { `) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) - yield* tx.run( - `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`, - ) - yield* tx.run( - `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`, - ) + yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`) + yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) - yield* tx.run( - `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, - ) - yield* tx.run( - `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, - ) - yield* tx.run( - `CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`, - ) - yield* tx.run( - `CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`, - ) + yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`) + yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 81f375680..cdaefe2cf 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -9,11 +9,14 @@ import { CommandV2 } from "./command" import { AgentV2 } from "./agent" import { PluginBoot } from "./plugin/boot" import { Project } from "./project" +import { ProjectCopy } from "./project/copy" +import { ProjectDirectories } from "./project/directories" import { EventV2 } from "./event" import { Credential } from "./credential" import { Npm } from "./npm" import { ModelsDev } from "./models-dev" import { FSUtil } from "./fs-util" +import { Git } from "./git" import { Global } from "./global" import { Database } from "./database/database" import { PermissionV2 } from "./permission" @@ -63,6 +66,7 @@ export class LocationServiceMap extends LayerMap.Service()(" CommandV2.locationLayer, AgentV2.locationLayer, PluginBoot.locationLayer, + ProjectCopy.locationLayer, FileSystem.locationLayer, Watcher.locationLayer, Pty.locationLayer, @@ -98,6 +102,11 @@ export class LocationServiceMap extends LayerMap.Service()(" Layer.provide(skillGuidance), Layer.provide(referenceGuidance), ) + + // Kick off a background project copy refresh to update locations now that we + // have a location + const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services)) + return Layer.mergeAll( boot, services, @@ -110,6 +119,7 @@ export class LocationServiceMap extends LayerMap.Service()(" runner, builtInTools, referenceGuidance, + projectCopyRefresh, ).pipe(Layer.fresh) }, idleTimeToLive: "60 minutes", @@ -120,10 +130,12 @@ export class LocationServiceMap extends LayerMap.Service()(" Npm.defaultLayer, ModelsDev.defaultLayer, FSUtil.defaultLayer, + Git.defaultLayer, AppProcess.defaultLayer, Global.defaultLayer, Ripgrep.defaultLayer, Database.defaultLayer, + ProjectDirectories.defaultLayer, SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)), PermissionSaved.defaultLayer, RepositoryCache.defaultLayer, diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index a7589c121..c439da0fd 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,60 +2,41 @@ export * as ProjectV2 from "./project" export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" -import { asc, desc, eq } from "drizzle-orm" import path from "path" -import { AbsolutePath, withStatics } from "./schema" +import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" -import { Database } from "./database/database" import { Git } from "./git" import { LayerNode } from "./effect/layer-node" import { Hash } from "./util/hash" -import { ProjectDirectoryTable } from "./project/sql" +import { ProjectDirectories } from "./project/directories" +import { ProjectSchema } from "./project/schema" -export const ID = Schema.String.pipe( - Schema.brand("Project.ID"), - withStatics((schema) => ({ - global: schema.make("global"), - })), -) -export type ID = typeof ID.Type +export const ID = ProjectSchema.ID +export type ID = ProjectSchema.ID -export const Vcs = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("git"), - store: AbsolutePath, - }), -]) -export type Vcs = typeof Vcs.Type +export const Vcs = ProjectSchema.Vcs +export type Vcs = ProjectSchema.Vcs export class Info extends Schema.Class("Project.Info")({ id: ID, }) {} -export const DirectoriesInput = Schema.Struct({ - projectID: ID, -}).annotate({ identifier: "Project.DirectoriesInput" }) +export const DirectoriesInput = ProjectDirectories.ListInput export type DirectoriesInput = typeof DirectoriesInput.Type -export const Directories = Schema.Array( - Schema.Struct({ - directory: AbsolutePath, - type: Schema.Literals(["main", "root", "git_worktree"]), - }), -).annotate({ identifier: "Project.Directories" }) +export const Directories = ProjectDirectories.ListOutput export type Directories = typeof Directories.Type +export interface Resolved { + readonly previous?: ID + readonly id: ID + readonly directory: AbsolutePath + readonly vcs?: Vcs +} + export interface Interface { readonly directories: (input: DirectoriesInput) => Effect.Effect - readonly resolve: (input: AbsolutePath) => Effect.Effect< - { - previous?: ID - id: ID - directory: AbsolutePath - vcs?: Vcs - }, - never - > + readonly resolve: (input: AbsolutePath) => Effect.Effect /** * Temporary bridge method for writing the resolved project ID to the repo-local cache. * @@ -73,19 +54,12 @@ export class Service extends Context.Service()("@opencode/Pr export const layer = Layer.effect( Service, Effect.gen(function* () { - const db = (yield* Database.Service).db const fs = yield* FSUtil.Service const git = yield* Git.Service + const projectDirectories = yield* ProjectDirectories.Service const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { - const rows = yield* db - .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type }) - .from(ProjectDirectoryTable) - .where(eq(ProjectDirectoryTable.project_id, input.projectID)) - .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory)) - .all() - .pipe(Effect.orDie) - return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type })) + return yield* projectDirectories.list(input.projectID) }) const cached = Effect.fnUntraced(function* (dir: string) { @@ -156,8 +130,8 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provideMerge(ProjectDirectories.defaultLayer), ) -export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node]) +export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node]) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts index 3e67ea66d..7c1dc09e0 100644 --- a/packages/core/src/project/copy-strategies.ts +++ b/packages/core/src/project/copy-strategies.ts @@ -1,20 +1,18 @@ import path from "path" import { Effect } from "effect" import { AbsolutePath } from "../schema" -import { FSUtil } from "../fs-util" import { Git } from "../git" -import { DirectoryUnavailableError, type Copy, type Strategy, type StrategyID } from "./copy" +import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy" -export function makeStrategies(input: { +export function makeGitWorktreeStrategy(input: { git: Git.Interface - fs: FSUtil.Interface canonical: (directory: AbsolutePath) => Effect.Effect }) { const repo = (sourceDirectory: AbsolutePath) => ({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo - const gitWorktree: Strategy = { - id: "git_worktree", + return { + id: StrategyID.make("git_worktree"), create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) { yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory }) return { directory: yield* input.canonical(options.directory) } @@ -30,18 +28,12 @@ export function makeStrategies(input: { const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store const entries = yield* input.git.worktreeList(found) return yield* Effect.forEach(entries, (entry) => - entry === core - ? Effect.succeed(undefined) - : input.canonical(entry).pipe( - Effect.map((directory) => ({ directory })), - Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)), - ), - ).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined))) - }), - detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) { - return yield* input.fs.isFile(path.join(inputDirectory, ".git")) - }), - } + input.canonical(entry).pipe( + Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const), + Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)), + ), + ).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined))) - return new Map([[gitWorktree.id, gitWorktree]]) + }), + } satisfies Strategy } diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index de2beda98..670b0d2dc 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -1,34 +1,29 @@ export * as ProjectCopy from "./copy" -import { and, eq, inArray } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import path from "path" import { AbsolutePath } from "../schema" import { FSUtil } from "../fs-util" import { Git } from "../git" -import { Database } from "../database/database" -import { EventV2 } from "../event" import { LayerNode } from "../effect/layer-node" import { Project } from "../project" -import { ProjectDirectoryTable } from "./sql" -import { makeStrategies } from "./copy-strategies" +import { ProjectDirectories } from "./directories" +import { makeGitWorktreeStrategy } from "./copy-strategies" import { Slug } from "../util/slug" +import { EventV2 } from "../event" +import { Database } from "../database/database" +import { Location } from "../location" +import { PluginBoot } from "../plugin/boot" -export const StrategyID = Schema.Literal("git_worktree") +export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) export type StrategyID = typeof StrategyID.Type -export const DetectInput = Schema.Struct({ - directory: AbsolutePath, -}).annotate({ identifier: "ProjectCopy.DetectInput" }) -export type DetectInput = typeof DetectInput.Type - export const CreateInput = Schema.Struct({ projectID: Project.ID, strategy: StrategyID, sourceDirectory: AbsolutePath, directory: AbsolutePath, name: Schema.optional(Schema.String), - context: Schema.optional(Schema.String), }).annotate({ identifier: "ProjectCopy.CreateInput" }) export type CreateInput = typeof CreateInput.Type @@ -44,12 +39,22 @@ export const RefreshInput = Schema.Struct({ }).annotate({ identifier: "ProjectCopy.RefreshInput" }) export type RefreshInput = typeof RefreshInput.Type +export const RefreshResult = Schema.Struct({ + updated: Schema.Array(AbsolutePath), + removed: Schema.Array(AbsolutePath), +}).annotate({ identifier: "ProjectCopy.RefreshResult" }) +export type RefreshResult = typeof RefreshResult.Type + export const Copy = Schema.Struct({ directory: AbsolutePath, }).annotate({ identifier: "ProjectCopy.Copy" }) export type Copy = typeof Copy.Type -export type DirectoryType = "main" | "root" | StrategyID +export const ListEntry = Schema.Struct({ + directory: AbsolutePath, + type: Schema.Literals(["root", "copy"]), +}).annotate({ identifier: "ProjectCopy.ListEntry" }) +export type ListEntry = typeof ListEntry.Type export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass()( "ProjectCopy.SourceDirectoryNotFoundError", @@ -66,16 +71,27 @@ export class DirectoryUnavailableError extends Schema.TaggedErrorClass()( - "ProjectCopy.StrategyNotFoundError", +export class InvalidDirectoryError extends Schema.TaggedErrorClass()( + "ProjectCopy.InvalidDirectoryError", { directory: AbsolutePath }, ) {} +export class StrategyUnavailableError extends Schema.TaggedErrorClass()( + "ProjectCopy.StrategyUnavailableError", + { strategy: StrategyID }, +) {} + +export class DuplicateStrategyError extends Schema.TaggedErrorClass()( + "ProjectCopy.DuplicateStrategyError", + { strategy: StrategyID }, +) {} + export type Error = | SourceDirectoryNotFoundError | DestinationExistsError | DirectoryUnavailableError - | StrategyNotFoundError + | InvalidDirectoryError + | StrategyUnavailableError | Git.WorktreeError export interface Strategy { @@ -88,8 +104,7 @@ export interface Strategy { directory: AbsolutePath force: boolean }) => Effect.Effect - readonly list: (directory: AbsolutePath) => Effect.Effect - readonly detect: (directory: AbsolutePath) => Effect.Effect + readonly list: (directory: AbsolutePath) => Effect.Effect } export const Event = { @@ -100,21 +115,46 @@ export const Event = { } export interface Interface { - readonly detect: (input: DetectInput) => Effect.Effect + readonly register: (strategy: Strategy) => Effect.Effect readonly create: (input: CreateInput) => Effect.Effect readonly remove: (input: RemoveInput) => Effect.Effect - readonly refresh: (input: RefreshInput) => Effect.Effect + readonly refresh: (input: RefreshInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/ProjectCopy") {} +export const refreshAfterBoot = Effect.gen(function* () { + const location = yield* Location.Service + const boot = yield* PluginBoot.Service + const copies = yield* Service + yield* Effect.gen(function* () { + yield* boot.wait() + yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id }) + const result = yield* copies.refresh({ projectID: location.project.id }) + yield* Effect.logInfo("project copy refresh done", { + projectID: location.project.id, + updated: result.updated, + removed: result.removed, + }) + }).pipe( + Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })), + Effect.forkScoped, + Effect.asVoid, + ) +}) + export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const git = yield* Git.Service - const events = yield* EventV2.Service + const directories = yield* ProjectDirectories.Service const db = (yield* Database.Service).db + const events = yield* EventV2.Service + + const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) { + if (update) yield* events.publish(Event.Updated, { projectID }) + }) const canonical = Effect.fnUntraced(function* (input: AbsolutePath) { const resolved = AbsolutePath.make(FSUtil.resolve(input)) @@ -122,76 +162,34 @@ export const layer = Layer.effect( return resolved }) - const registry = makeStrategies({ git, fs, canonical }) + const registry = new Map() + + const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) { + if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id }) + registry.set(strategy.id, strategy) + }) + + // Register default strategies + yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie) + + const strategies = () => Array.from(registry.values()) const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) { const sourceDirectory = yield* canonical(input) - const row = yield* db - .select({ directory: ProjectDirectoryTable.directory }) - .from(ProjectDirectoryTable) - .where( - and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, sourceDirectory)), - ) - .get() - .pipe(Effect.orDie) - if (!row) return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory }) + if (!(yield* directories.contains({ projectID, directory: sourceDirectory }))) + return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory }) return sourceDirectory }) - const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath, type: StrategyID) { - return yield* db - .transaction( - (tx) => - Effect.gen(function* () { - const row = yield* tx - .select({ directory: ProjectDirectoryTable.directory }) - .from(ProjectDirectoryTable) - .where( - and( - eq(ProjectDirectoryTable.project_id, projectID), - eq(ProjectDirectoryTable.directory, copyDirectory), - ), - ) - .get() - if (row) return false - yield* tx - .insert(ProjectDirectoryTable) - .values({ project_id: projectID, directory: copyDirectory, type }) - .run() - return true - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) - }) - - const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath) { - return ( - (yield* db - .delete(ProjectDirectoryTable) - .where( - and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)), - ) - .returning({ directory: ProjectDirectoryTable.directory }) - .get() - .pipe(Effect.orDie)) !== undefined - ) - }) - - const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) { - if (update) yield* events.publish(Event.Updated, { projectID }) - }) - - const strategy = (id: StrategyID) => registry.get(id) as Strategy - - const detect = Effect.fn("ProjectCopy.detect")(function* (input: DetectInput) { - for (const strategy of registry.values()) { - if (yield* strategy.detect(input.directory)) return strategy.id - } - return undefined + const getStrategy = Effect.fnUntraced(function* (id: StrategyID) { + const found = registry.get(id) + if (!found) return yield* new StrategyUnavailableError({ strategy: id }) + return found }) const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) { + const selected = yield* getStrategy(input.strategy) + const sourceDirectory = yield* source(input.sourceDirectory, input.projectID) yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie) const name = input.name ?? Slug.create() let suffix = 1 @@ -202,78 +200,100 @@ export const layer = Layer.effect( copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`)) } - const result = yield* strategy(input.strategy).create({ + const result = yield* selected.create({ directory: copyDirectory, - sourceDirectory: yield* source(input.sourceDirectory, input.projectID), + sourceDirectory, }) - yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy)) + yield* changed( + input.projectID, + yield* directories.create({ + projectID: input.projectID, + directory: result.directory, + strategy: input.strategy, + behavior: "replace", + }), + ) return result }) const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) { const copyDirectory = yield* canonical(input.directory) - const id = yield* detect({ directory: copyDirectory }) - if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory }) - yield* strategy(id).remove({ directory: copyDirectory, force: input.force }) - yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory)) + const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory }) + if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory }) + yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({ + directory: copyDirectory, + force: input.force, + }) + yield* changed( + input.projectID, + yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }), + ) }) const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) { - const roots = yield* db - .select({ directory: ProjectDirectoryTable.directory }) - .from(ProjectDirectoryTable) - .where( - and( - eq(ProjectDirectoryTable.project_id, input.projectID), - inArray(ProjectDirectoryTable.type, ["main", "root"]), - ), - ) - .all() - .pipe(Effect.orDie) - const sourceDirectories = yield* Effect.forEach(roots, (item) => canonical(AbsolutePath.make(item.directory)), { - concurrency: "unbounded", - }) + const stored = yield* directories.list(input.projectID) + const checked = yield* Effect.forEach( + stored, + (item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))), + { concurrency: "unbounded" }, + ) + const sourceDirectories = checked + .filter((item) => item.strategy === undefined && item.exists) + .map((item) => item.directory) const discovered = yield* Effect.forEach( sourceDirectories, (sourceDirectory) => - Effect.forEach(registry.values(), (strategy) => - strategy - .list(sourceDirectory) - .pipe(Effect.map((items) => items.map((item) => ({ ...item, type: strategy.id })))), + Effect.forEach(strategies(), (strategy) => + strategy.list(sourceDirectory).pipe( + Effect.map((items) => + items.map((item) => ({ + directory: item.directory, + strategy: item.type === "copy" ? strategy.id : undefined, + })), + ), + ), ), { concurrency: "unbounded" }, ).pipe( Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()), ) - const stored = yield* db - .select({ directory: ProjectDirectoryTable.directory }) - .from(ProjectDirectoryTable) - .where(eq(ProjectDirectoryTable.project_id, input.projectID)) - .all() - .pipe(Effect.orDie) - const inserted = yield* Effect.forEach(discovered, (item) => - insert(input.projectID, item.directory, item.type), - ).pipe(Effect.map((items) => items.some(Boolean))) - const removed = yield* Effect.forEach(stored, (item) => - fs - .isDir(item.directory) - .pipe( - Effect.flatMap((exists) => - exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.directory)), + const removed = checked.filter((item) => !item.exists).map((item) => item.directory) + const result = yield* db + .transaction((tx) => + Effect.all({ + updated: Effect.forEach(discovered, (item) => + directories.create( + { + projectID: input.projectID, + directory: item.directory, + strategy: item.strategy, + behavior: "replace", + }, + tx, + ), ), - ), - ).pipe(Effect.map((items) => items.some(Boolean))) - yield* changed(input.projectID, inserted || removed) + removed: Effect.forEach(removed, (directory) => + directories.remove({ projectID: input.projectID, directory }, tx), + ), + }), + ) + .pipe(Effect.orDie) + const changes = { + updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory), + removed: removed.filter((_, index) => result.removed[index]), + } + yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0) + return changes }) - return Service.of({ detect, create, remove, refresh }) + return Service.of({ + register, + create, + remove, + refresh, + }) }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node]) +export const locationLayer = layer +export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node]) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts new file mode 100644 index 000000000..b060e3124 --- /dev/null +++ b/packages/core/src/project/directories.ts @@ -0,0 +1,162 @@ +export * as ProjectDirectories from "./directories" + +import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" +import { Context, Effect, Layer, Schema } from "effect" +import { Database } from "../database/database" +import { LayerNode } from "../effect/layer-node" +import { AbsolutePath, optionalOmitUndefined } from "../schema" +import { ProjectSchema } from "./schema" +import { ProjectDirectoryTable } from "./sql" +import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" + +export interface Directory { + readonly directory: AbsolutePath + readonly strategy?: string +} + +export const CreateInput = Schema.Struct({ + projectID: ProjectSchema.ID, + directory: AbsolutePath, + strategy: Schema.optional(Schema.String), + behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional), +}) +export type CreateInput = typeof CreateInput.Type + +export const RemoveInput = Schema.Struct({ + projectID: ProjectSchema.ID, + directory: AbsolutePath, +}) +export type RemoveInput = typeof RemoveInput.Type + +type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase +export type Transaction = Parameters[0]>[0] + +export const ListInput = Schema.Struct({ + projectID: ProjectSchema.ID, +}).annotate({ identifier: "Project.DirectoriesInput" }) +export type ListInput = typeof ListInput.Type + +export const ListOutput = Schema.Array( + Schema.Struct({ + directory: AbsolutePath, + strategy: optionalOmitUndefined(Schema.String), + }), +).annotate({ identifier: "Project.Directories" }) +export type ListOutput = typeof ListOutput.Type + +export interface Interface { + readonly list: (projectID: ProjectSchema.ID) => Effect.Effect> + readonly get: (input: { + projectID: ProjectSchema.ID + directory: AbsolutePath + }) => Effect.Effect + readonly contains: (input: { projectID: ProjectSchema.ID; directory: AbsolutePath }) => Effect.Effect + readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect + readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ProjectDirectories") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = (yield* Database.Service).db + + const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) { + const insert = (tx ?? db) + .insert(ProjectDirectoryTable) + .values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy }) + const query = + input.behavior === "replace" + ? insert.onConflictDoUpdate({ + target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory], + set: { strategy: input.strategy ?? null }, + setWhere: input.strategy + ? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy)) + : isNotNull(ProjectDirectoryTable.strategy), + }) + : insert.onConflictDoNothing() + return ( + (yield* query + .returning({ directory: ProjectDirectoryTable.directory }) + .get() + .pipe(Effect.orDie)) !== undefined + ) + }) + + const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) { + return ( + (yield* (tx ?? db) + .delete(ProjectDirectoryTable) + .where( + and( + eq(ProjectDirectoryTable.project_id, input.projectID), + eq(ProjectDirectoryTable.directory, input.directory), + ), + ) + .returning({ directory: ProjectDirectoryTable.directory }) + .get() + .pipe(Effect.orDie)) !== undefined + ) + }) + + const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) { + const rows = yield* db + .select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy }) + .from(ProjectDirectoryTable) + .where(eq(ProjectDirectoryTable.project_id, projectID)) + .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory)) + .all() + .pipe(Effect.orDie) + return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined })) + }) + + const contains = Effect.fn("ProjectDirectories.contains")(function* (input: { + projectID: ProjectSchema.ID + directory: AbsolutePath + }) { + return ( + (yield* db + .select({ directory: ProjectDirectoryTable.directory }) + .from(ProjectDirectoryTable) + .where( + and( + eq(ProjectDirectoryTable.project_id, input.projectID), + eq(ProjectDirectoryTable.directory, input.directory), + ), + ) + .get() + .pipe(Effect.orDie)) !== undefined + ) + }) + + const get = Effect.fn("ProjectDirectories.get")(function* (input: { + projectID: ProjectSchema.ID + directory: AbsolutePath + }) { + const row = yield* db + .select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy }) + .from(ProjectDirectoryTable) + .where( + and( + eq(ProjectDirectoryTable.project_id, input.projectID), + eq(ProjectDirectoryTable.directory, input.directory), + ), + ) + .get() + .pipe(Effect.orDie) + return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined + }) + + return Service.of({ + list, + get, + contains, + create, + remove, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +export const node = LayerNode.make(layer, [Database.node]) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts new file mode 100644 index 000000000..51d9581cc --- /dev/null +++ b/packages/core/src/project/schema.ts @@ -0,0 +1,20 @@ +export * as ProjectSchema from "./schema" + +import { Schema } from "effect" +import { AbsolutePath, withStatics } from "../schema" + +export const ID = Schema.String.pipe( + Schema.brand("Project.ID"), + withStatics((schema) => ({ + global: schema.make("global"), + })), +) +export type ID = typeof ID.Type + +export const Vcs = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("git"), + store: AbsolutePath, + }), +]) +export type Vcs = typeof Vcs.Type diff --git a/packages/core/src/project/sql.ts b/packages/core/src/project/sql.ts index c3954b771..ab05fdac4 100644 --- a/packages/core/src/project/sql.ts +++ b/packages/core/src/project/sql.ts @@ -1,10 +1,10 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" import * as DatabasePath from "../database/path" import { Timestamps } from "../database/schema.sql" -import { ProjectV2 } from "../project" +import { ProjectSchema } from "./schema" export const ProjectTable = sqliteTable("project", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), worktree: DatabasePath.absoluteColumn().notNull(), vcs: text(), name: text(), @@ -21,11 +21,12 @@ export const ProjectDirectoryTable = sqliteTable( "project_directory", { project_id: text() - .$type() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - directory: text().notNull(), - type: text().$type<"main" | "root" | "git_worktree">().notNull(), + directory: DatabasePath.absoluteColumn().notNull(), + type: text().$type<"main" | "root" | "git_worktree">(), + strategy: text(), time_created: integer() .notNull() .$default(() => Date.now()), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index da867cc24..73e93bfe5 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -27,7 +27,7 @@ import { ApplicationTools } from "../src/tool/application-tools" const applicationTools = ApplicationTools.layer const it = testEffect( Layer.merge( - applicationTools, + Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer), LocationServiceMap.layer.pipe( Layer.provide(applicationTools), Layer.provide( @@ -135,4 +135,5 @@ describe("LocationServiceMap", () => { ), ), ) + }) diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 0af8da1b9..5f7fbb16d 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -11,6 +11,7 @@ import { Git } from "@opencode-ai/core/git" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -22,11 +23,13 @@ import { testEffect } from "./lib/effect" const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) +const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events)) const project = Project.layer.pipe( Layer.provide(database), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(directories), ) const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( @@ -45,7 +48,7 @@ const layer = MoveSession.layer.pipe( Layer.provide(sessions), ) const it = testEffect( - Layer.mergeAll(layer, database, events, project, projector, store, SessionExecution.noopLayer, sessions), + Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions), ) function abs(input: string) { diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index f1ba10fb0..823d8d842 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -12,23 +12,28 @@ import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/core/project" import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectCopy } from "@opencode-ai/core/project/copy" +import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const databaseLayer = Database.layerFromPath(":memory:") const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) +const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) const copyLayer = ProjectCopy.layer.pipe( Layer.provide(databaseLayer), + Layer.provide(directoriesLayer), Layer.provide(eventLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) -const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer)) +const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer)) function abs(input: string) { return AbsolutePath.make(input) } +const gitWorktree = ProjectCopy.StrategyID.make("git_worktree") + async function initRepo(directory: string) { await $`git init`.cwd(directory).quiet() await $`git config core.fsmonitor false`.cwd(directory).quiet() @@ -55,7 +60,7 @@ function setup() { .pipe(Effect.orDie) yield* db .insert(ProjectDirectoryTable) - .values({ project_id: projectID, directory: sourceDirectory, type: "main" }) + .values({ project_id: projectID, directory: sourceDirectory }) .run() .pipe(Effect.orDie) return { root, sourceDirectory, projectID, db } @@ -65,7 +70,7 @@ function setup() { function stored(projectID: Project.ID) { return Database.Service.use(({ db }) => db - .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type }) + .select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy }) .from(ProjectDirectoryTable) .where(eq(ProjectDirectoryTable.project_id, projectID)) .all() @@ -77,18 +82,40 @@ function stored(projectID: Project.ID) { } describe("ProjectCopy", () => { - it.live("detects linked git worktrees but not root checkouts", () => + it.effect("accepts arbitrary non-empty strategy ids", () => + Effect.sync(() => { + expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot") + expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow() + expect(() => ProjectCopy.StrategyID.make(" ")).toThrow() + }), + ) + + it.effect("rejects duplicate strategies and reports unavailable ids", () => Effect.gen(function* () { const input = yield* setup() const copy = yield* ProjectCopy.Service - const target = abs(`${input.root.path}-copy-detected`) - yield* Effect.addFinalizer(() => - Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore), - ) - yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet()) + const strategy: ProjectCopy.Strategy = { + id: ProjectCopy.StrategyID.make("test/duplicate"), + create: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + list: () => Effect.succeed([]), + } - expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined() - expect(yield* copy.detect({ directory: target })).toBe("git_worktree") + yield* copy.register(strategy) + expect(yield* copy.register(strategy).pipe(Effect.flip)).toBeInstanceOf(ProjectCopy.DuplicateStrategyError) + + const unavailable = ProjectCopy.StrategyID.make("acme/missing") + const error = yield* copy + .create({ + projectID: input.projectID, + strategy: unavailable, + sourceDirectory: input.sourceDirectory, + directory: abs(`${input.root.path}-missing-strategy`), + name: "copy", + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError) + if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable) }), ) @@ -110,7 +137,7 @@ describe("ProjectCopy", () => { const created = yield* copy.create({ projectID: input.projectID, - strategy: "git_worktree", + strategy: gitWorktree, sourceDirectory: input.sourceDirectory, directory: parent, name: "copy", @@ -118,15 +145,15 @@ describe("ProjectCopy", () => { expect(created.directory).toBe(target) expect(yield* stored(input.projectID)).toEqual( [ - { directory: input.sourceDirectory, type: "main" as const }, - { directory: created.directory, type: "git_worktree" as const }, + { directory: input.sourceDirectory, strategy: null }, + { directory: created.directory, strategy: "git_worktree" }, ].toSorted((a, b) => a.directory.localeCompare(b.directory)), ) expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID }) yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false }) - expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }]) + expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }]) expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false) }), ) @@ -142,7 +169,7 @@ describe("ProjectCopy", () => { ) const created = yield* copy.create({ projectID: input.projectID, - strategy: "git_worktree", + strategy: gitWorktree, sourceDirectory: input.sourceDirectory, directory: parent, name: "copy", @@ -158,7 +185,7 @@ describe("ProjectCopy", () => { expect(error.operation).toBe("remove") expect(error.forceRequired).toBe(true) } - expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" }) + expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" }) expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true) yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true }) @@ -166,6 +193,28 @@ describe("ProjectCopy", () => { }), ) + it.live("preserves copies whose stored strategy is unavailable", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const unavailable = abs(`${input.root.path}-copy-unavailable`) + yield* Effect.promise(() => fs.mkdir(unavailable)) + yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true }))) + yield* input.db + .insert(ProjectDirectoryTable) + .values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" }) + .run() + .pipe(Effect.orDie) + + const error = yield* copy + .remove({ projectID: input.projectID, directory: unavailable, force: false }) + .pipe(Effect.flip) + + expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError) + expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" }) + }), + ) + it.live("adds a numeric suffix when a copy directory already exists", () => Effect.gen(function* () { const input = yield* setup() @@ -181,7 +230,7 @@ describe("ProjectCopy", () => { const created = yield* copy.create({ projectID: input.projectID, - strategy: "git_worktree", + strategy: gitWorktree, sourceDirectory: input.sourceDirectory, directory: parent, name: "copy", @@ -219,7 +268,7 @@ describe("ProjectCopy", () => { const error = yield* copy .create({ projectID: input.projectID, - strategy: "git_worktree", + strategy: gitWorktree, sourceDirectory: input.sourceDirectory, directory: parent, name: "copy", @@ -227,7 +276,8 @@ describe("ProjectCopy", () => { .pipe(Effect.flip) expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError) - expect(error.directory).toBe(abs(path.join(parent, "copy-10"))) + if (error instanceof ProjectCopy.DestinationExistsError) + expect(error.directory).toBe(abs(path.join(parent, "copy-10"))) }), ) @@ -263,25 +313,30 @@ describe("ProjectCopy", () => { Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore), ) yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet()) + yield* input.db + .insert(ProjectDirectoryTable) + .values({ project_id: input.projectID, directory: target }) + .run() + .pipe(Effect.orDie) const fiber = yield* events .subscribe(ProjectCopy.Event.Updated) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* copy.refresh({ projectID: input.projectID }) - const discovered = abs(yield* Effect.promise(() => fs.realpath(target))) + expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] }) + expect(yield* stored(input.projectID)).toEqual( [ - { directory: input.sourceDirectory, type: "main" as const }, - { directory: discovered, type: "git_worktree" as const }, + { directory: input.sourceDirectory, strategy: null }, + { directory: discovered, strategy: "git_worktree" }, ].toSorted((a, b) => a.directory.localeCompare(b.directory)), ) expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID }) yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet()) - yield* copy.refresh({ projectID: input.projectID }) - expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }]) + expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] }) + expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }]) }), ) @@ -303,8 +358,8 @@ describe("ProjectCopy", () => { const discovered = abs(yield* Effect.promise(() => fs.realpath(target))) expect(yield* stored(input.projectID)).toEqual( [ - { directory: input.sourceDirectory, type: "main" as const }, - { directory: discovered, type: "git_worktree" as const }, + { directory: input.sourceDirectory, strategy: null }, + { directory: discovered, strategy: "git_worktree" }, ].toSorted((a, b) => a.directory.localeCompare(b.directory)), ) }), @@ -314,7 +369,27 @@ describe("ProjectCopy", () => { Effect.gen(function* () { const copy = yield* ProjectCopy.Service - yield* copy.refresh({ projectID: Project.ID.make("missing-project") }) + expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({ + updated: [], + removed: [], + }) + }), + ) + + it.live("refresh removes missing ordinary checkouts", () => + Effect.gen(function* () { + const input = yield* setup() + const missing = abs(`${input.root.path}-missing-checkout`) + yield* input.db + .insert(ProjectDirectoryTable) + .values({ project_id: input.projectID, directory: missing }) + .run() + .pipe(Effect.orDie) + const copy = yield* ProjectCopy.Service + + expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] }) + + expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null }) }), ) }) diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts new file mode 100644 index 000000000..235e27af4 --- /dev/null +++ b/packages/core/test/project-directories.test.ts @@ -0,0 +1,69 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectDirectories } from "@opencode-ai/core/project/directories" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "./lib/effect" + +const database = Database.layerFromPath(":memory:") +const events = EventV2.layer.pipe(Layer.provide(database)) +const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) +const it = testEffect(Layer.mergeAll(database, events, directories)) + +const projectID = Project.ID.make("project-directories") +const directory = AbsolutePath.make("/tmp/project-directories") + +function setup() { + return Database.Service.use(({ db }) => + db + .insert(ProjectTable) + .values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie), + ) +} + +describe("ProjectDirectories", () => { + it.effect("decodes directory schemas", () => + Effect.sync(() => { + expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID }) + expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }]) + }), + ) + + it.effect("creates once and ignores conflicts", () => + Effect.gen(function* () { + yield* setup() + const service = yield* ProjectDirectories.Service + + expect(yield* service.create({ projectID, directory })).toBe(true) + expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false) + expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }]) + }), + ) + + it.effect("replaces the strategy when requested", () => + Effect.gen(function* () { + yield* setup() + const service = yield* ProjectDirectories.Service + yield* service.create({ projectID, directory, strategy: "old/strategy" }) + + expect( + yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }), + ).toBe(true) + expect( + yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }), + ).toBe(false) + expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true) + expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false) + expect( + yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }), + ).toBe(true) + expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }]) + }), + ) +}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 3608939d7..45fba0d18 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -4,24 +4,27 @@ import fs from "fs/promises" import path from "path" import { Effect, Layer, Schema } from "effect" import { ProjectV2 } from "@opencode-ai/core/project" -import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" import { Database } from "@opencode-ai/core/database/database" import { FSUtil } from "@opencode-ai/core/fs-util" import { Git } from "@opencode-ai/core/git" import { AbsolutePath } from "@opencode-ai/core/schema" import { Hash } from "@opencode-ai/core/util/hash" +import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const databaseLayer = Database.layerFromPath(":memory:") +const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) const it = testEffect( Layer.mergeAll( ProjectV2.layer.pipe( - Layer.provide(databaseLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(directoriesLayer), + Layer.provide(databaseLayer), ), databaseLayer, + directoriesLayer, ), ) @@ -51,54 +54,6 @@ async function rootCommit(dir: string) { return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim() } -describe("Project directories schemas", () => { - it.effect("decodes project directory input and inline directory results", () => - Effect.sync(() => { - expect(Schema.decodeUnknownSync(ProjectV2.DirectoriesInput)({ projectID: ProjectV2.ID.make("project") })).toEqual( - { - projectID: ProjectV2.ID.make("project"), - }, - ) - expect( - Schema.decodeUnknownSync(ProjectV2.Directories)([ - { directory: AbsolutePath.make("/tmp/project"), type: "main" }, - ]), - ).toEqual([{ directory: AbsolutePath.make("/tmp/project"), type: "main" }]) - }), - ) - - it.effect("lists stored project directories newest first for the requested project", () => - Effect.gen(function* () { - const project = yield* ProjectV2.Service - const { db } = yield* Database.Service - const projectID = ProjectV2.ID.make("directories-project") - const otherID = ProjectV2.ID.make("directories-other") - yield* db - .insert(ProjectTable) - .values([ - { id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 }, - { id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 }, - ]) - .run() - .pipe(Effect.orDie) - yield* db - .insert(ProjectDirectoryTable) - .values([ - { project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root", time_created: 2 }, - { project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main", time_created: 1 }, - { project_id: otherID, directory: AbsolutePath.make("/other"), type: "main", time_created: 3 }, - ]) - .run() - .pipe(Effect.orDie) - - expect(yield* project.directories({ projectID })).toEqual([ - { directory: AbsolutePath.make("/repo/z"), type: "root" }, - { directory: AbsolutePath.make("/repo/a"), type: "main" }, - ]) - }), - ) -}) - describe("ProjectV2.resolve", () => { it.live("returns global for non-git directory", () => Effect.gen(function* () { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1fb8afec8..5f1b64743 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { and, eq, sql } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Flag } from "@opencode-ai/core/flag/flag" @@ -14,7 +15,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" -import { ProjectCopy } from "@opencode-ai/core/project/copy" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -138,7 +138,7 @@ export const layer = Layer.effect( const proc = yield* AppProcess.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const projectV2 = yield* ProjectV2.Service - const projectCopy = yield* ProjectCopy.Service + const projectDirectories = yield* ProjectDirectories.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const { db } = yield* Database.Service @@ -197,6 +197,12 @@ export const layer = Layer.effect( .run() } + // Project directories may be shared across distinct + // checkouts which have diverged. Clear the directory + // list and rely on it being re-populated to ensure + // accuracy + yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run() + yield* d .update(SessionTable) .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) @@ -221,27 +227,11 @@ export const layer = Layer.effect( }) { if (input.projectID === ProjectV2.ID.global) return const opened = AbsolutePath.make(FSUtil.resolve(input.directory)) - const type = yield* projectCopy.detect({ directory: opened }) - - yield* db - .transaction( - (d) => - Effect.gen(function* () { - const hasMain = yield* d - .select({ directory: ProjectDirectoryTable.directory }) - .from(ProjectDirectoryTable) - .where( - and(eq(ProjectDirectoryTable.project_id, input.projectID), eq(ProjectDirectoryTable.type, "main")), - ) - .get() - yield* d - .insert(ProjectDirectoryTable) - .values({ directory: opened, project_id: input.projectID, type: type ?? (hasMain ? "root" : "main") }) - .onConflictDoNothing() - .run() - }), - { behavior: "immediate" }, - ) + yield* projectDirectories + .create({ + directory: opened, + projectID: input.projectID, + }) .pipe( Effect.catchCause((cause) => Effect.logWarning("project directory persistence failed", { projectID: input.projectID, cause }), @@ -505,7 +495,7 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectCopy.defaultLayer), + Layer.provide(ProjectDirectories.defaultLayer), Layer.provide(AppProcess.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(FSUtil.defaultLayer), @@ -520,7 +510,7 @@ export const node = LayerNode.make(layer, [ AppProcess.node, CrossSpawnSpawner.node, ProjectV2.node, - ProjectCopy.node, + ProjectDirectories.node, EventV2Bridge.node, RuntimeFlags.node, Database.node, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts index 98dc4f3c3..877784b39 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts @@ -1,87 +1,31 @@ -import { ProjectCopy } from "@opencode-ai/core/project/copy" import { ProjectV2 } from "@opencode-ai/core/project" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { - WorkspaceRoutingMiddleware, - WorkspaceRoutingQuery, - WorkspaceRoutingQueryFields, -} from "../middleware/workspace-routing" -import { described } from "./metadata" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" -const root = "/experimental/project/:projectID/copy" -const CopyQuery = Schema.Struct({ - workspace: WorkspaceRoutingQueryFields.workspace, +export const GenerateNamePayload = Schema.Struct({ + context: Schema.optional(Schema.String), }) -export const CreatePayload = Schema.Struct({ - strategy: ProjectCopy.StrategyID, - directory: ProjectCopy.CreateInput.fields.directory, - name: ProjectCopy.CreateInput.fields.name, - context: ProjectCopy.CreateInput.fields.context, -}) -export const RemovePayload = Schema.Struct({ - directory: ProjectCopy.RemoveInput.fields.directory, - force: ProjectCopy.RemoveInput.fields.force, -}) - -export class ApiProjectCopyError extends Schema.ErrorClass("ProjectCopyError")( - { - name: Schema.Literal("ProjectCopyError"), - data: Schema.Struct({ - message: Schema.String, - forceRequired: Schema.optional(Schema.Boolean), - }), - }, - { httpApiStatus: 400 }, -) {} - -export const ProjectCopyApi = HttpApi.make("projectCopy").add( - HttpApiGroup.make("projectCopy") +export const ProjectCopyApi = HttpApi.make("projectCopyName").add( + HttpApiGroup.make("projectCopyName") .add( - HttpApiEndpoint.post("create", root, { - params: { projectID: ProjectV2.ID }, - query: CopyQuery, - payload: CreatePayload, - success: described(ProjectCopy.Copy, "Project copy created"), - error: ApiProjectCopyError, - }).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: CopyQuery, - payload: RemovePayload, - success: described(HttpApiSchema.NoContent, "Project copy removed"), - error: ApiProjectCopyError, - }).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`, { + HttpApiEndpoint.post("generateName", "/experimental/project/:projectID/copy/generate-name", { params: { projectID: ProjectV2.ID }, query: WorkspaceRoutingQuery, - payload: HttpApiSchema.NoContent, - success: described(HttpApiSchema.NoContent, "Project copies refreshed"), - error: ApiProjectCopyError, + payload: GenerateNamePayload, + success: Schema.Struct({ name: Schema.String }), }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.projectCopy.refresh", - summary: "Refresh project copies", - description: "Discover local project copies using one or all configured strategies.", + identifier: "experimental.projectCopy.generateName", + summary: "Generate project copy name", + description: "Generate a short name for a project copy from task context.", }), ), ) - .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) + .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy naming routes." })) .middleware(InstanceContextMiddleware) .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts index 647c7b6e7..84a0a7165 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts @@ -1,75 +1,45 @@ -import { ProjectCopy } from "@opencode-ai/core/project/copy" -import { Git } from "@opencode-ai/core/git" -import { ProjectV2 } from "@opencode-ai/core/project" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { InstanceState } from "@/effect/instance-state" +import { Agent } from "@/agent/agent" +import { Provider } from "@/provider/provider" +import { LLM } from "@/session/llm" +import { MessageID, SessionID } from "@/session/schema" +import { Slug } from "@opencode-ai/core/util/slug" +import { LLMEvent } from "@opencode-ai/llm" import { Effect, Stream } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy" -import { Agent } from "@/agent/agent" -import { LLM } from "@/session/llm" -import { LLMEvent } from "@opencode-ai/llm" -import { MessageID, SessionID } from "@/session/schema" -import { Provider } from "@/provider/provider" -import { Slug } from "@opencode-ai/core/util/slug" -const FALLBACK_AGENT: Agent.Info = { - name: "title", - mode: "primary" as const, +const COPY_NAME_AGENT: Agent.Info = { + name: "project-copy-name", + mode: "primary", permission: [], options: {}, native: true, prompt: "", } -function badRequest(effect: Effect.Effect) { - return effect.pipe( - Effect.mapError( - (error) => - new ApiProjectCopyError({ - name: "ProjectCopyError", - data: { - message: message(error), - forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined, - }, - }), - ), - ) -} - -export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) => +export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopyName", (handlers) => Effect.gen(function* () { const llm = yield* LLM.Service - const agent = yield* Agent.Service const provider = yield* Provider.Service - const service = yield* ProjectCopy.Service const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) { const text = context?.trim() if (!text) return Slug.create() - const [titleAgent, fallback] = yield* Effect.all( - [ - agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))), - provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))), - ], - { concurrency: 2 }, - ) + const fallback = yield* provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))) if (!fallback) return Slug.create() - const model = titleAgent.model - ? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID) - : ((yield* provider.getSmallModel(fallback.providerID)) ?? - (yield* provider.getModel(fallback.providerID, fallback.modelID))) + const model = + (yield* provider.getSmallModel(fallback.providerID)) ?? + (yield* provider.getModel(fallback.providerID, fallback.modelID)) const sessionID = SessionID.descending() const result = yield* llm .stream({ - agent: titleAgent, + agent: COPY_NAME_AGENT, user: { id: MessageID.ascending(), sessionID, role: "user", time: { created: Date.now() }, - agent: titleAgent.name, + agent: COPY_NAME_AGENT.name, model: { providerID: model.providerID, modelID: model.id }, }, system: [], @@ -78,12 +48,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec model, sessionID, retries: 2, - messages: [ - { - role: "user", - content: `Generate a short 3-4 word name that describes this task:\n${text}`, - }, - ], + messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }], }) .pipe( Stream.filter(LLMEvent.is.textDelta), @@ -91,47 +56,20 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec Stream.mkString, ) const output = result.trim() - return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create() + return output ? slugify(output.split(/\s+/).slice(0, 3).join(" ")) : Slug.create() }) - const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: { - params: { projectID: ProjectV2.ID } - payload: typeof CreatePayload.Type - }) { - const name = - ctx.payload.name ?? - (yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create())))) - return yield* badRequest( - service.create({ - ...ctx.payload, - name, - 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) + return handlers.handle("generateName", (ctx) => + generateName(ctx.payload.context).pipe( + Effect.catchCause((cause) => + Effect.logWarning("project copy name generation failed", { + projectID: ctx.params.projectID, + cause, + }).pipe(Effect.as(Slug.create())), + ), + Effect.map((name) => ({ name })), + ), + ) }), ) @@ -143,15 +81,3 @@ function slugify(input: string) { .replace(/^-+/, "") .replace(/-+$/, "") } - -function message(error: ProjectCopy.Error) { - if (error instanceof ProjectCopy.SourceDirectoryNotFoundError) - return `Project copy source not found: ${error.directory}` - if (error instanceof ProjectCopy.DestinationExistsError) - return `Project copy destination already exists: ${error.directory}` - if (error instanceof ProjectCopy.DirectoryUnavailableError) - return `Project copy directory unavailable: ${error.directory}` - if (error instanceof ProjectCopy.StrategyNotFoundError) - return `Project copy strategy not found for: ${error.directory}` - return error.message -} diff --git a/packages/opencode/test/project/project-directory.test.ts b/packages/opencode/test/project/project-directory.test.ts index 4e8cf9cad..fcec9085d 100644 --- a/packages/opencode/test/project/project-directory.test.ts +++ b/packages/opencode/test/project/project-directory.test.ts @@ -26,7 +26,7 @@ function directories(projectID: ProjectV2.ID) { Effect.orDie, Effect.map((rows) => rows - .map((row) => ({ directory: row.directory, type: row.type })) + .map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined })) .toSorted((a, b) => a.directory.localeCompare(b.directory)), ), ), @@ -41,7 +41,9 @@ describe("Project directory persistence", () => { const result = yield* project.fromDirectory(tmp) - expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }]) + expect(yield* directories(result.project.id)).toEqual([ + { directory: AbsolutePath.make(tmp), strategy: undefined }, + ]) }), ) @@ -54,7 +56,9 @@ describe("Project directory persistence", () => { const next = yield* project.fromDirectory(tmp) expect(next.project.id).toBe(result.project.id) - expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }]) + expect(yield* directories(result.project.id)).toEqual([ + { directory: AbsolutePath.make(tmp), strategy: undefined }, + ]) }), ) @@ -73,8 +77,8 @@ describe("Project directory persistence", () => { expect(yield* directories(main.project.id)).toEqual( [ - { directory: tmp, type: "main" as const }, - { directory: worktree, type: "git_worktree" as const }, + { directory: AbsolutePath.make(tmp), strategy: undefined }, + { directory: AbsolutePath.make(worktree), strategy: undefined }, ].toSorted((a, b) => a.directory.localeCompare(b.directory)), ) }), @@ -92,7 +96,9 @@ describe("Project directory persistence", () => { const result = yield* project.fromDirectory(worktree) - expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }]) + expect(yield* directories(result.project.id)).toEqual([ + { directory: AbsolutePath.make(worktree), strategy: undefined }, + ]) }), ) @@ -113,8 +119,8 @@ describe("Project directory persistence", () => { expect(yield* directories(main.project.id)).toEqual( [ - { directory: tmp, type: "main" as const }, - { directory: clone, type: "root" as const }, + { directory: AbsolutePath.make(tmp), strategy: undefined }, + { directory: AbsolutePath.make(clone), strategy: undefined }, ].toSorted((a, b) => a.directory.localeCompare(b.directory)), ) }), @@ -134,7 +140,9 @@ describe("Project directory persistence", () => { const result = yield* project.fromDirectory(worktree) - expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }]) + expect(yield* directories(result.project.id)).toEqual([ + { directory: AbsolutePath.make(worktree), strategy: undefined }, + ]) }), ) @@ -163,7 +171,35 @@ describe("Project directory persistence", () => { yield* project.fromDirectory(tmp) - expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }]) + expect(yield* directories(remoteID)).toEqual([ + { directory: AbsolutePath.make(tmp), strategy: undefined }, + ]) + }), + ) + + it.live("clears stale directories when the project id changes", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const project = yield* Project.Service + const original = yield* project.fromDirectory(tmp) + const stale = AbsolutePath.make(tmp + "-stale-checkout") + const { db } = yield* Database.Service + yield* db + .insert(ProjectDirectoryTable) + .values({ project_id: original.project.id, directory: stale }) + .run() + .pipe(Effect.orDie) + const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration")) + yield* Effect.promise(() => + $`git remote add origin git@github.com:project-directory-test/migration.git`.cwd(tmp).quiet(), + ) + + yield* project.fromDirectory(tmp) + + expect(yield* directories(original.project.id)).toEqual([]) + expect(yield* directories(remoteID)).toEqual([ + { directory: AbsolutePath.make(tmp), strategy: undefined }, + ]) }), ) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index d6ff5b7bd..05e205cd8 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -19,7 +19,7 @@ import { NodePath } from "@effect/platform-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" -import { ProjectCopy } from "@opencode-ai/core/project/copy" +import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -73,7 +73,7 @@ function projectLayerWithFailure(failArg: string) { Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))), Layer.provide(mockGitFailure(failArg)), Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectCopy.defaultLayer), + Layer.provide(ProjectDirectories.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer), @@ -86,7 +86,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters ctx.project()) + .at((ctx) => ({ + path: route("/experimental/project/{projectID}/copy/generate-name", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json(200, (body) => { + object(body) + check(typeof body.name === "string" && body.name.length > 0, "generated copy name should be non-empty") + }), http.protected .post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create") .seeded((ctx) => ctx.project()) diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 61d9b4c0e..57d13c20c 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -34,7 +34,7 @@ function json(response: HttpClientResponse.HttpClientResponse) { } describe("project directories and copies endpoints", () => { - type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" } + type ProjectDirectory = { directory: string; strategy?: string } it.instance( "lists directories and manages git worktree copies", @@ -44,7 +44,7 @@ describe("project directories and copies endpoints", () => { const current = yield* request(test.directory, "/project/current") const projectID = (yield* json<{ id: string }>(current)).id const base = `/project/${projectID}` - const copies = `/experimental/project/${projectID}/copy` + const copies = `/experimental/project/${projectID}/copy?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}` const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy") const createdDirectory = path.join(createdParent, "copy") yield* Effect.addFinalizer(() => @@ -53,7 +53,15 @@ describe("project directories and copies endpoints", () => { const initial = yield* request(test.directory, `${base}/directories`) expect(initial.status).toBe(200) - expect(yield* json(initial)).toEqual([{ directory: test.directory, type: "main" }]) + expect(yield* json(initial)).toEqual([{ directory: test.directory }]) + + const generated = yield* request(test.directory, `/experimental/project/${projectID}/copy/generate-name`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ context: undefined }), + }) + expect(generated.status).toBe(200) + expect((yield* json<{ name: string }>(generated)).name).toBeString() const create = yield* request(test.directory, copies, { method: "POST", @@ -67,7 +75,7 @@ describe("project directories and copies endpoints", () => { const listed = yield* request(test.directory, `${base}/directories`) expect(yield* json(listed)).toContainEqual({ directory: created.directory, - type: "git_worktree", + strategy: "git_worktree", }) yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty")) @@ -94,14 +102,18 @@ describe("project directories and copies endpoints", () => { Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore), ) yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet()) - const refresh = yield* request(test.directory, `${copies}/refresh`, { + const refresh = yield* request( + test.directory, + `/experimental/project/${projectID}/copy/refresh?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`, + { method: "POST", - }) + }, + ) expect(refresh.status).toBe(204) const refreshed = yield* request(test.directory, `${base}/directories`) expect(yield* json(refreshed)).toEqual([ - { directory: externalDirectory, type: "git_worktree" }, - { directory: test.directory, type: "main" }, + { directory: externalDirectory, strategy: "git_worktree" }, + { directory: test.directory }, ]) }), { git: true }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f651dc106..b01c8fd04 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -36,12 +36,8 @@ import type { ExperimentalConsoleSwitchOrgResponses, ExperimentalControlPlaneMoveSessionErrors, ExperimentalControlPlaneMoveSessionResponses, - ExperimentalProjectCopyCreateErrors, - ExperimentalProjectCopyCreateResponses, - ExperimentalProjectCopyRefreshErrors, - ExperimentalProjectCopyRefreshResponses, - ExperimentalProjectCopyRemoveErrors, - ExperimentalProjectCopyRemoveResponses, + ExperimentalProjectCopyGenerateNameErrors, + ExperimentalProjectCopyGenerateNameResponses, ExperimentalResourceListErrors, ExperimentalResourceListResponses, ExperimentalSessionBackgroundErrors, @@ -303,6 +299,12 @@ import type { V2PermissionSavedListResponses, V2PermissionSavedRemoveErrors, V2PermissionSavedRemoveResponses, + V2ProjectCopyCreateErrors, + V2ProjectCopyCreateResponses, + V2ProjectCopyRefreshErrors, + V2ProjectCopyRefreshResponses, + V2ProjectCopyRemoveErrors, + V2ProjectCopyRemoveResponses, V2ProviderGetErrors, V2ProviderGetResponses, V2ProviderListErrors, @@ -842,107 +844,18 @@ export class Resource extends HeyApiClient { export class ProjectCopy extends HeyApiClient { /** - * Remove project copy + * Generate project copy name * - * Remove a local physical copy of a project using the selected strategy. + * Generate a short name for a project copy from task context. */ - public remove( + public generateName( parameters: { projectID: string - workspace?: string directory?: string - force?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "projectID" }, - { in: "query", key: "workspace" }, - { in: "body", key: "directory" }, - { in: "body", key: "force" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete< - ExperimentalProjectCopyRemoveResponses, - ExperimentalProjectCopyRemoveErrors, - ThrowOnError - >({ - url: "/experimental/project/{projectID}/copy", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Create project copy - * - * Create a local physical copy of a project using the selected strategy. - */ - public create( - parameters: { - projectID: string workspace?: string - strategy?: "git_worktree" - directory?: string - name?: string context?: string }, options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "projectID" }, - { in: "query", key: "workspace" }, - { in: "body", key: "strategy" }, - { in: "body", key: "directory" }, - { in: "body", key: "name" }, - { in: "body", key: "context" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ExperimentalProjectCopyCreateResponses, - ExperimentalProjectCopyCreateErrors, - ThrowOnError - >({ - url: "/experimental/project/{projectID}/copy", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Refresh project copies - * - * Discover local project copies using one or all configured strategies. - */ - public refresh( - parameters: { - projectID: string - directory?: string - workspace?: string - }, - options?: Options, ) { const params = buildClientParams( [parameters], @@ -952,18 +865,24 @@ export class ProjectCopy extends HeyApiClient { { in: "path", key: "projectID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "context" }, ], }, ], ) return (options?.client ?? this.client).post< - ExperimentalProjectCopyRefreshResponses, - ExperimentalProjectCopyRefreshErrors, + ExperimentalProjectCopyGenerateNameResponses, + ExperimentalProjectCopyGenerateNameErrors, ThrowOnError >({ - url: "/experimental/project/{projectID}/copy/refresh", + url: "/experimental/project/{projectID}/copy/generate-name", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } } @@ -6241,6 +6160,122 @@ export class Reference extends HeyApiClient { } } +export class ProjectCopy2 extends HeyApiClient { + public remove( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + directory?: string + force?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "location" }, + { in: "body", key: "directory" }, + { in: "body", key: "force" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2ProjectCopyRemoveResponses, + V2ProjectCopyRemoveErrors, + ThrowOnError + >({ + url: "/experimental/project/{projectID}/copy", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + public create( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + strategy?: string + directory?: string + name?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "location" }, + { in: "body", key: "strategy" }, + { in: "body", key: "directory" }, + { in: "body", key: "name" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post( + { + url: "/experimental/project/{projectID}/copy", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }, + ) + } + + public refresh( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2ProjectCopyRefreshResponses, + V2ProjectCopyRefreshErrors, + ThrowOnError + >({ + url: "/experimental/project/{projectID}/copy/refresh", + ...options, + ...params, + }) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -6316,6 +6351,11 @@ export class V2 extends HeyApiClient { get reference(): Reference { return (this._reference ??= new Reference({ client: this.client })) } + + private _projectCopy?: ProjectCopy2 + get projectCopy(): ProjectCopy2 { + return (this._projectCopy ??= new ProjectCopy2({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 92f09a991..334f5d469 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -56,6 +56,7 @@ export type Event = | EventPermissionV2Asked | EventPermissionV2Replied | EventReferenceUpdated + | EventProjectDirectoriesUpdated | EventFileWatcherUpdated | EventPtyCreated | EventPtyUpdated @@ -75,7 +76,6 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted - | EventProjectDirectoriesUpdated | EventProjectUpdated | EventSessionStatus | EventSessionIdle @@ -1294,6 +1294,13 @@ export type GlobalEvent = { [key: string]: unknown } } + | { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } + } | { id: string type: "file.watcher.updated" @@ -1479,13 +1486,6 @@ export type GlobalEvent = { messageID: string } } - | { - id: string - type: "project.directories.updated" - properties: { - projectID: string - } - } | { id: string type: "project.updated" @@ -2464,14 +2464,6 @@ export type ProjectNotFoundError = { message: string } -export type ProjectCopyError = { - name: "ProjectCopyError" - data: { - message: string - forceRequired?: boolean - } -} - export type PtyNotFoundError = { _tag: "PtyNotFoundError" ptyID: string @@ -2767,6 +2759,14 @@ export type ProviderNotFoundError = { message: string } +export type ProjectCopyError = { + name: "ProjectCopyError" + data: { + message: string + forceRequired?: boolean + } +} + export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } @@ -3699,13 +3699,9 @@ export type ConfigV2ExperimentalPolicy = { export type ProjectDirectories = Array<{ directory: string - type: "main" | "root" | "git_worktree" + strategy?: string }> -export type ProjectCopyCopy = { - directory: string -} - export type LocationInfo = { directory: string workspaceID?: string @@ -4226,6 +4222,10 @@ export type ReferenceInfo = { source: ReferenceLocalSource | ReferenceGitSource } +export type ProjectCopyCopy = { + directory: string +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -4938,6 +4938,14 @@ export type EventReferenceUpdated = { } } +export type EventProjectDirectoriesUpdated = { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } +} + export type EventFileWatcherUpdated = { id: string type: "file.watcher.updated" @@ -5087,14 +5095,6 @@ export type EventCommandExecuted = { } } -export type EventProjectDirectoriesUpdated = { - id: string - type: "project.directories.updated" - properties: { - projectID: string - } -} - export type EventProjectUpdated = { id: string type: "project.updated" @@ -6994,107 +6994,41 @@ export type ProjectDirectoriesResponses = { export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof ProjectDirectoriesResponses] -export type ExperimentalProjectCopyRemoveData = { +export type ExperimentalProjectCopyGenerateNameData = { body?: { - directory: string - force: boolean - } - path: { - projectID: string - } - query?: { - workspace?: string - } - url: "/experimental/project/{projectID}/copy" -} - -export type ExperimentalProjectCopyRemoveErrors = { - /** - * ProjectCopyError | InvalidRequestError - */ - 400: ProjectCopyError | InvalidRequestError -} - -export type ExperimentalProjectCopyRemoveError = - ExperimentalProjectCopyRemoveErrors[keyof ExperimentalProjectCopyRemoveErrors] - -export type ExperimentalProjectCopyRemoveResponses = { - /** - * Project copy removed - */ - 204: void -} - -export type ExperimentalProjectCopyRemoveResponse = - ExperimentalProjectCopyRemoveResponses[keyof ExperimentalProjectCopyRemoveResponses] - -export type ExperimentalProjectCopyCreateData = { - body?: { - strategy: "git_worktree" - directory: string - name?: string context?: string } path: { projectID: string } - query?: { - workspace?: string - } - url: "/experimental/project/{projectID}/copy" -} - -export type ExperimentalProjectCopyCreateErrors = { - /** - * ProjectCopyError | InvalidRequestError - */ - 400: ProjectCopyError | InvalidRequestError -} - -export type ExperimentalProjectCopyCreateError = - ExperimentalProjectCopyCreateErrors[keyof ExperimentalProjectCopyCreateErrors] - -export type ExperimentalProjectCopyCreateResponses = { - /** - * Project copy created - */ - 200: ProjectCopyCopy -} - -export type ExperimentalProjectCopyCreateResponse = - ExperimentalProjectCopyCreateResponses[keyof ExperimentalProjectCopyCreateResponses] - -export type ExperimentalProjectCopyRefreshData = { - body?: never - path: { - projectID: string - } query?: { directory?: string workspace?: string } - url: "/experimental/project/{projectID}/copy/refresh" + url: "/experimental/project/{projectID}/copy/generate-name" } -export type ExperimentalProjectCopyRefreshErrors = { +export type ExperimentalProjectCopyGenerateNameErrors = { /** - * ProjectCopyError | InvalidRequestError + * Bad request */ - 400: ProjectCopyError | InvalidRequestError + 400: BadRequestError } -export type ExperimentalProjectCopyRefreshError = - ExperimentalProjectCopyRefreshErrors[keyof ExperimentalProjectCopyRefreshErrors] +export type ExperimentalProjectCopyGenerateNameError = + ExperimentalProjectCopyGenerateNameErrors[keyof ExperimentalProjectCopyGenerateNameErrors] -export type ExperimentalProjectCopyRefreshResponses = { +export type ExperimentalProjectCopyGenerateNameResponses = { /** - * Project copies refreshed + * Success */ - 204: void + 200: { + name: string + } } -export type ExperimentalProjectCopyRefreshResponse = - ExperimentalProjectCopyRefreshResponses[keyof ExperimentalProjectCopyRefreshResponses] +export type ExperimentalProjectCopyGenerateNameResponse = + ExperimentalProjectCopyGenerateNameResponses[keyof ExperimentalProjectCopyGenerateNameResponses] export type PtyShellsData = { body?: never @@ -10951,6 +10885,109 @@ export type V2ReferenceListResponses = { export type V2ReferenceListResponse = V2ReferenceListResponses[keyof V2ReferenceListResponses] +export type V2ProjectCopyRemoveData = { + body?: { + directory: string + force: boolean + } + path: { + projectID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/experimental/project/{projectID}/copy" +} + +export type V2ProjectCopyRemoveErrors = { + /** + * ProjectCopyError | InvalidRequestError + */ + 400: ProjectCopyError | InvalidRequestError +} + +export type V2ProjectCopyRemoveError = V2ProjectCopyRemoveErrors[keyof V2ProjectCopyRemoveErrors] + +export type V2ProjectCopyRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2ProjectCopyRemoveResponse = V2ProjectCopyRemoveResponses[keyof V2ProjectCopyRemoveResponses] + +export type V2ProjectCopyCreateData = { + body?: { + strategy: string + directory: string + name?: string + } + path: { + projectID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/experimental/project/{projectID}/copy" +} + +export type V2ProjectCopyCreateErrors = { + /** + * ProjectCopyError | InvalidRequestError + */ + 400: ProjectCopyError | InvalidRequestError +} + +export type V2ProjectCopyCreateError = V2ProjectCopyCreateErrors[keyof V2ProjectCopyCreateErrors] + +export type V2ProjectCopyCreateResponses = { + /** + * ProjectCopy.Copy + */ + 200: ProjectCopyCopy +} + +export type V2ProjectCopyCreateResponse = V2ProjectCopyCreateResponses[keyof V2ProjectCopyCreateResponses] + +export type V2ProjectCopyRefreshData = { + body?: never + path: { + projectID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/experimental/project/{projectID}/copy/refresh" +} + +export type V2ProjectCopyRefreshErrors = { + /** + * ProjectCopyError | InvalidRequestError + */ + 400: ProjectCopyError | InvalidRequestError +} + +export type V2ProjectCopyRefreshError = V2ProjectCopyRefreshErrors[keyof V2ProjectCopyRefreshErrors] + +export type V2ProjectCopyRefreshResponses = { + /** + * + */ + 204: void +} + +export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 9fd169af9..6853ad16c 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -17,6 +17,7 @@ import { Authorization } from "./middleware/authorization" import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" +import { ProjectCopyGroup } from "./groups/project-copy" export const Api = HttpApi.make("server") .add(HealthGroup) @@ -35,6 +36,7 @@ export const Api = HttpApi.make("server") .add(EventGroup) .add(QuestionGroup) .add(ReferenceGroup) + .add(ProjectCopyGroup) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/server/src/groups/location.ts b/packages/server/src/groups/location.ts index e1249bb95..6979c6703 100644 --- a/packages/server/src/groups/location.ts +++ b/packages/server/src/groups/location.ts @@ -74,14 +74,23 @@ export const LocationGroup = HttpApiGroup.make("server.location") function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { const query = new URL(request.url, "http://localhost").searchParams const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"] + const directory = + query.get("location[directory]") || + (request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd()) return Location.Ref.make({ - directory: AbsolutePath.make( - query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(), - ), + directory: AbsolutePath.make(directory), workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined, }) } +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + export const layer = Layer.effect( LocationMiddleware, Effect.gen(function* () { diff --git a/packages/server/src/groups/project-copy.ts b/packages/server/src/groups/project-copy.ts new file mode 100644 index 000000000..e9b5e8f67 --- /dev/null +++ b/packages/server/src/groups/project-copy.ts @@ -0,0 +1,57 @@ +import { ProjectCopy } from "@opencode-ai/core/project/copy" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" + +const root = "/experimental/project/:projectID/copy" + +export class ProjectCopyError extends Schema.ErrorClass("ProjectCopyError")( + { + name: Schema.Literal("ProjectCopyError"), + data: Schema.Struct({ + message: Schema.String, + forceRequired: Schema.optional(Schema.Boolean), + }), + }, + { httpApiStatus: 400 }, +) {} + +const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"])) +const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"])) + +export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") + .add( + HttpApiEndpoint.post("projectCopy.create", root, { + params: { projectID: ProjectV2.ID }, + query: LocationQuery, + payload: CreatePayload, + success: ProjectCopy.Copy, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })), + ) + .add( + HttpApiEndpoint.delete("projectCopy.remove", root, { + params: { projectID: ProjectV2.ID }, + query: LocationQuery, + payload: RemovePayload, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })), + ) + .add( + HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, { + params: { projectID: ProjectV2.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })), + ) + .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) + .middleware(LocationMiddleware) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index ab9f72e0f..31eff1818 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -22,6 +22,7 @@ import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" import { Credential } from "@opencode-ai/core/credential" +import { ProjectCopyHandler } from "./handlers/project-copy" export const handlers = Layer.mergeAll( HealthHandler, @@ -40,6 +41,7 @@ export const handlers = Layer.mergeAll( EventHandler, QuestionHandler, ReferenceHandler, + ProjectCopyHandler, ).pipe( Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), diff --git a/packages/server/src/handlers/project-copy.ts b/packages/server/src/handlers/project-copy.ts new file mode 100644 index 000000000..0e22bbc87 --- /dev/null +++ b/packages/server/src/handlers/project-copy.ts @@ -0,0 +1,69 @@ +import { Location } from "@opencode-ai/core/location" +import { ProjectCopy } from "@opencode-ai/core/project/copy" +import { Git } from "@opencode-ai/core/git" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { ProjectCopyError } from "../groups/project-copy" + +export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) => + Effect.succeed( + handlers + .handle("projectCopy.create", (ctx) => + Effect.gen(function* () { + const copies = yield* ProjectCopy.Service + const location = yield* Location.Service + return yield* badRequest( + copies.create({ + ...ctx.payload, + projectID: ctx.params.projectID, + sourceDirectory: location.project.directory, + }), + ) + }), + ) + .handle("projectCopy.remove", (ctx) => + ProjectCopy.Service.use((copies) => + badRequest(copies.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe( + Effect.as(HttpApiSchema.NoContent.make()), + ), + ), + ) + .handle("projectCopy.refresh", (ctx) => + ProjectCopy.Service.use((copies) => + badRequest(copies.refresh({ projectID: ctx.params.projectID })).pipe( + Effect.as(HttpApiSchema.NoContent.make()), + ), + ), + ), + ), +) + +function badRequest(effect: Effect.Effect) { + return effect.pipe( + Effect.mapError( + (error) => + new ProjectCopyError({ + name: "ProjectCopyError", + data: { + message: message(error), + forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined, + }, + }), + ), + ) +} + +function message(error: ProjectCopy.Error) { + if (error instanceof ProjectCopy.SourceDirectoryNotFoundError) + return `Project copy source not found: ${error.directory}` + if (error instanceof ProjectCopy.DestinationExistsError) + return `Project copy destination already exists: ${error.directory}` + if (error instanceof ProjectCopy.DirectoryUnavailableError) + return `Project copy directory unavailable: ${error.directory}` + if (error instanceof ProjectCopy.InvalidDirectoryError) + return `Invalid project copy directory: ${error.directory}` + if (error instanceof ProjectCopy.StrategyUnavailableError) + return `Project copy strategy unavailable: ${error.strategy}` + return error.message +} diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index f06b0b2d1..30d525bff 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -16,14 +16,18 @@ import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" +import type { ProjectDirectories } from "@opencode-ai/sdk/v2" +import { useRoute } from "../context/route" export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +type ProjectDirectory = ProjectDirectories[number] export function DialogMoveSession(props: { projectID: string current?: MoveSessionSelection onSelect: (selection: MoveSessionSelection) => void - initialDirectories?: string[] + onCurrentChange?: (selection: MoveSessionSelection) => void + initialDirectories?: ProjectDirectory[] initialRemoving?: string }) { const dialog = useDialog() @@ -32,11 +36,13 @@ export function DialogMoveSession(props: { const { theme } = useTheme() const sync = useSync() const projectContext = useProject() + const route = useRoute() const toast = useToast() const paths = useTuiPaths() const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) + const [replacementCurrent, setReplacementCurrent] = createSignal() const deleteHint = useCommandShortcut("dialog.move_session.delete") function reopen(initialRemoving?: string) { @@ -52,8 +58,8 @@ export function DialogMoveSession(props: { return result.data?.id === projectID ? result.data.worktree : undefined }, ) - const project = createMemo(() => - projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(), + const currentCheckout = createMemo(() => + projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(), ) const [directories, { refetch }] = createResource( @@ -61,9 +67,12 @@ export function DialogMoveSession(props: { async (projectID) => { setWorking(true) try { - await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true }) + await sdk.client.v2.projectCopy.refresh( + { projectID, location: { directory: sdk.directory } }, + { throwOnError: true }, + ) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) - return directories.data?.map((item) => item.directory) ?? [] + return directories.data ?? [] } finally { setWorking(false) } @@ -71,56 +80,88 @@ export function DialogMoveSession(props: { { initialValue: props.initialDirectories }, ) + const currentDirectory = createMemo(() => + replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()), + ) + const currentRoot = createMemo(() => { + const directory = currentDirectory() + if (!directory) return + return ( + directories() + ?.filter((root) => contains(root.directory, directory)) + .toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory } + ) + }) + const options = createMemo[]>(() => { const data = directories() - const main = project() - if (directories.loading && !data && !main) return [{ title: "Loading project directories...", value: undefined }] - if (directories.error && !data && !main) return [{ title: "Failed to load project directories", value: undefined }] - const roots = [...new Set(main ? [main, ...(data ?? [])] : (data ?? []))] + const current = currentRoot()?.directory + if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }] + if (directories.error && !data && !current) + return [{ title: "Failed to load project directories", value: undefined }] + const roots = [...(data ?? [])] + if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) + roots.sort((a, b) => { + if (a.directory === current) return -1 + if (b.directory === current) return 1 + if (Boolean(a.strategy) !== Boolean(b.strategy)) return a.strategy ? 1 : -1 + if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length + return 0 + }) if (roots.length === 0) return [{ title: "No project directories found", value: undefined }] + const subdirectories = sync.data.session .filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path)) .map((session) => session.directory) - .filter((directory) => !roots.includes(directory)) + .filter((directory) => !roots.some((root) => root.directory === directory)) .filter((directory, index, directories) => directories.indexOf(directory) === index) .map((location) => ({ location, root: roots .filter((root) => { - const relative = path.relative(root, location) + const relative = path.relative(root.directory, location) return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative) }) - .toSorted((a, b) => b.length - a.length)[0], + .toSorted((a, b) => b.directory.length - a.directory.length)[0], })) - .filter((item): item is { location: string; root: string } => item.root !== undefined) - const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => { + .filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined) + + const list = [...roots.map((root) => ({ location: root.directory, root })), ...subdirectories].toSorted((a, b) => { const root = roots.indexOf(a.root) - roots.indexOf(b.root) if (root !== 0) return root - if (a.location === a.root) return -1 - if (b.location === b.root) return 1 + if (a.location === a.root.directory) return -1 + if (b.location === b.root.directory) return 1 return a.location.localeCompare(b.location) }) const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12) + return list.map((item) => { const title = abbreviateHome(item.location, paths.home) - const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location) + const suffix = + item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location) const visible = Locale.truncateLeft(title, titleWidth) const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length const deleting = toDelete() === item.location const isRemoving = removing() === item.location return { - title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title, + title, titleView: isRemoving ? ( Deleting {item.location} - ) : !deleting && suffix ? ( + ) : deleting ? ( + Press {deleteHint()} again to confirm + ) : suffix ? ( <> {visible.slice(0, split)} {visible.slice(split)} ) : undefined, bg: deleting ? theme.error : undefined, - value: { type: "directory", directory: item.location, subdirectory: item.location !== item.root } as const, - category: item.root === main ? "Project" : "Working copies", + value: { + type: "directory", + directory: item.location, + subdirectory: item.location !== item.root.directory, + } as const, + category: item.root.directory === current ? "Current" : "Other", titleWidth, truncateTitle: "left" as const, } @@ -128,32 +169,56 @@ export function DialogMoveSession(props: { }) const current = createMemo(() => { - if (directories.loading || loadedProject.loading || !props.current) return - if (props.current.type === "new") return props.current - const directory = props.current.directory - return options().find((option) => option.value?.type === "directory" && option.value.directory === directory)?.value + if (directories.loading || loadedProject.loading) return + const replacement = replacementCurrent() + if (replacement) return { type: "directory", directory: replacement, subdirectory: false } as const + return props.current }) + async function removedCurrent(current: boolean) { + if (!current) return false + const fallback = projectContext.data.project.mainDir + if (fallback) setReplacementCurrent(fallback) + if (route.data.type === "session") { + route.navigate({ type: "home" }) + dialog.clear() + return true + } + if (fallback) { + props.onCurrentChange?.({ type: "directory", directory: fallback, subdirectory: false }) + return true + } + dialog.clear() + return true + } + async function remove(option: DialogSelectOption) { if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return const data = directories() - const main = project() - if (!data || !main || option.value.directory === main || !data.includes(option.value.directory)) return - if (toDelete() !== option.value.directory) { - setToDelete(option.value.directory) + const selected = option.value + const root = data?.find((item) => item.directory === selected.directory) + if (!root?.strategy) return + const deletingCurrent = selected.directory === currentRoot()?.directory + if (toDelete() !== selected.directory) { + setToDelete(selected.directory) return } setToDelete(undefined) - setRemoving(option.value.directory) + setRemoving(selected.directory) setWorking(true) - const result = await sdk.client.experimental.projectCopy - .remove({ projectID: props.projectID, directory: option.value.directory, force: false }) + const result = await sdk.client.v2.projectCopy + .remove({ + projectID: props.projectID, + location: { directory: sdk.directory }, + directory: selected.directory, + force: false, + }) .catch((error) => ({ error })) if (result.error) { setRemoving(undefined) setWorking(false) if ("data" in result.error && result.error.data.forceRequired) { - const status = await sdk.client.vcs.status({ directory: option.value.directory }).catch(() => undefined) + const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined) const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], { title: "Delete working copy?", message: "This working copy has file changes. Do you want to delete it anyway?", @@ -162,9 +227,14 @@ export function DialogMoveSession(props: { reopen() return } - reopen(option.value.directory) - const forced = await sdk.client.experimental.projectCopy - .remove({ projectID: props.projectID, directory: option.value.directory, force: true }) + reopen(selected.directory) + const forced = await sdk.client.v2.projectCopy + .remove({ + projectID: props.projectID, + location: { directory: sdk.directory }, + directory: selected.directory, + force: true, + }) .catch((error) => ({ error })) if (forced.error) { toast.show({ @@ -172,7 +242,12 @@ export function DialogMoveSession(props: { title: "Failed to delete project copy", message: errorMessage(forced.error), }) + reopen() + return } + setRemoving(undefined) + setWorking(false) + if (await removedCurrent(deletingCurrent)) return reopen() return } @@ -185,6 +260,8 @@ export function DialogMoveSession(props: { } await refetch() setRemoving(undefined) + setWorking(false) + if (await removedCurrent(deletingCurrent)) return } onMount(() => dialog.setSize("xlarge")) @@ -219,11 +296,11 @@ export function DialogMoveSession(props: { { command: "dialog.move_session.delete", title: "delete", - disabled: (option) => - !option?.value || - option.value.type !== "directory" || - option.value.subdirectory || - option.value.directory === project(), + disabled: (option) => { + const value = option?.value + if (!value || value.type !== "directory" || value.subdirectory) return true + return !directories()?.find((item) => item.directory === value.directory)?.strategy + }, onTrigger: remove, }, { @@ -236,3 +313,9 @@ export function DialogMoveSession(props: { ) } + +function contains(root: string, directory: string) { + if (root === directory) return true + const relative = path.relative(root, directory) + return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative) +} diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 56107fd8b..f3f5a94bf 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -9,6 +9,7 @@ import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { useHomeSessionDestination } from "../../routes/home/session-destination" +import { useProject } from "../../context/project" function moveReminderText(directory: string) { return `The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` @@ -20,6 +21,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const sync = useSync() const toast = useToast() const homeDestination = useHomeSessionDestination() + const project = useProject() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) @@ -31,12 +33,17 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess setCreating(true) setProgress("Creating copy") try { - const result = await sdk.client.experimental.projectCopy.create( + const generated = await sdk.client.experimental.projectCopy.generateName( + { projectID, context }, + { throwOnError: true }, + ) + const result = await sdk.client.v2.projectCopy.create( { projectID, + location: { directory: sdk.directory }, strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)), - context, + name: generated.data.name, }, { throwOnError: true }, ) @@ -74,8 +81,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess directory: session.directory, subdirectory: !!session.path, } - : undefined) + : { + type: "directory", + directory: project.instance.directory(), + subdirectory: project.instance.directory() !== project.instance.path().worktree, + }) } + onCurrentChange={(selection) => homeDestination?.setDestination(selection)} onSelect={(selection) => { const sessionID = input.sessionID() if (!sessionID) { diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index 0969a389a..d73a17e7e 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -44,12 +44,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex const directories = project.data?.id ? await sdk.client.project.directories({ projectID: project.data.id, workspace }) : undefined - batch(() => { setStore("instance", "path", reconcile(instancePath.data || defaultPath)) setStore("project", "id", project.data?.id) setStore("project", "worktree", project.data?.worktree) - setStore("project", "mainDir", directories?.data?.find((item) => item.type === "main")?.directory) + setStore("project", "mainDir", directories?.data?.findLast((item) => item.strategy === undefined)?.directory) }) }