feat(core): refactor project copies for v2 (#31943)
This commit is contained in:
parent
8d97c8d412
commit
c2e6b18076
33 changed files with 1461 additions and 829 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "abd2f920-b822-49af-b8a7-2e48367d424f",
|
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
|
||||||
"prevIds": ["f25f9126-c7dc-4882-9ff4-af27e11d2da1"],
|
"prevIds": [
|
||||||
|
"abd2f920-b822-49af-b8a7-2e48367d424f"
|
||||||
|
],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
|
|
@ -622,7 +624,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": true,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": null,
|
"default": null,
|
||||||
"generated": null,
|
"generated": null,
|
||||||
|
|
@ -630,6 +632,16 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "strategy",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "project_directory"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
|
|
@ -1501,9 +1513,13 @@
|
||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1512,9 +1528,13 @@
|
||||||
"table": "workspace"
|
"table": "workspace"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["active_account_id"],
|
"columns": [
|
||||||
|
"active_account_id"
|
||||||
|
],
|
||||||
"tableTo": "account",
|
"tableTo": "account",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "SET NULL",
|
"onDelete": "SET NULL",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1523,9 +1543,13 @@
|
||||||
"table": "account_state"
|
"table": "account_state"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["aggregate_id"],
|
"columns": [
|
||||||
|
"aggregate_id"
|
||||||
|
],
|
||||||
"tableTo": "event_sequence",
|
"tableTo": "event_sequence",
|
||||||
"columnsTo": ["aggregate_id"],
|
"columnsTo": [
|
||||||
|
"aggregate_id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1534,9 +1558,13 @@
|
||||||
"table": "event"
|
"table": "event"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1545,9 +1573,13 @@
|
||||||
"table": "permission"
|
"table": "permission"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1556,9 +1588,13 @@
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1567,9 +1603,13 @@
|
||||||
"table": "message"
|
"table": "message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["message_id"],
|
"columns": [
|
||||||
|
"message_id"
|
||||||
|
],
|
||||||
"tableTo": "message",
|
"tableTo": "message",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1578,9 +1618,13 @@
|
||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1589,9 +1633,13 @@
|
||||||
"table": "session_context_epoch"
|
"table": "session_context_epoch"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1600,9 +1648,13 @@
|
||||||
"table": "session_input"
|
"table": "session_input"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1611,9 +1663,13 @@
|
||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id"],
|
"columns": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1622,9 +1678,13 @@
|
||||||
"table": "session"
|
"table": "session"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1633,9 +1693,13 @@
|
||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": ["id"],
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1644,133 +1708,174 @@
|
||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["email", "url"],
|
"columns": [
|
||||||
|
"email",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "control_account_pk",
|
"name": "control_account_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "control_account"
|
"table": "control_account"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["project_id", "directory"],
|
"columns": [
|
||||||
|
"project_id",
|
||||||
|
"directory"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_directory_pk",
|
"name": "project_directory_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id", "position"],
|
"columns": [
|
||||||
|
"session_id",
|
||||||
|
"position"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "todo_pk",
|
"name": "todo_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "workspace_pk",
|
"name": "workspace_pk",
|
||||||
"table": "workspace",
|
"table": "workspace",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["name"],
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "data_migration_pk",
|
"name": "data_migration_pk",
|
||||||
"table": "data_migration",
|
"table": "data_migration",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_state_pk",
|
"name": "account_state_pk",
|
||||||
"table": "account_state",
|
"table": "account_state",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_pk",
|
"name": "account_pk",
|
||||||
"table": "account",
|
"table": "account",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "credential_pk",
|
"name": "credential_pk",
|
||||||
"table": "credential",
|
"table": "credential",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["aggregate_id"],
|
"columns": [
|
||||||
|
"aggregate_id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_sequence_pk",
|
"name": "event_sequence_pk",
|
||||||
"table": "event_sequence",
|
"table": "event_sequence",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_pk",
|
"name": "event_pk",
|
||||||
"table": "event",
|
"table": "event",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "permission_pk",
|
"name": "permission_pk",
|
||||||
"table": "permission",
|
"table": "permission",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_pk",
|
"name": "project_pk",
|
||||||
"table": "project",
|
"table": "project",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "message_pk",
|
"name": "message_pk",
|
||||||
"table": "message",
|
"table": "message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "part_pk",
|
"name": "part_pk",
|
||||||
"table": "part",
|
"table": "part",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_context_epoch_pk",
|
"name": "session_context_epoch_pk",
|
||||||
"table": "session_context_epoch",
|
"table": "session_context_epoch",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_input_pk",
|
"name": "session_input_pk",
|
||||||
"table": "session_input",
|
"table": "session_input",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_message_pk",
|
"name": "session_message_pk",
|
||||||
"table": "session_message",
|
"table": "session_message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["id"],
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_pk",
|
"name": "session_pk",
|
||||||
"table": "session",
|
"table": "session",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": ["session_id"],
|
"columns": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_share_pk",
|
"name": "session_share_pk",
|
||||||
"table": "session_share",
|
"table": "session_share",
|
||||||
|
|
@ -2088,4 +2193,4 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"renames": []
|
"renames": []
|
||||||
}
|
}
|
||||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -36,5 +36,6 @@ export const migrations = (
|
||||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||||
import("./migration/20260611035744_credential"),
|
import("./migration/20260611035744_credential"),
|
||||||
import("./migration/20260611192811_lush_chimera"),
|
import("./migration/20260611192811_lush_chimera"),
|
||||||
|
import("./migration/20260612174303_project_dir_strategy"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -101,7 +101,8 @@ export default {
|
||||||
CREATE TABLE \`project_directory\` (
|
CREATE TABLE \`project_directory\` (
|
||||||
\`project_id\` text NOT NULL,
|
\`project_id\` text NOT NULL,
|
||||||
\`directory\` text NOT NULL,
|
\`directory\` text NOT NULL,
|
||||||
\`type\` text NOT NULL,
|
\`type\` text,
|
||||||
|
\`strategy\` text,
|
||||||
\`time_created\` integer NOT NULL,
|
\`time_created\` integer NOT NULL,
|
||||||
CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
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
|
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 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 INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||||
yield* tx.run(
|
yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
|
||||||
`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 \`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_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 \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
||||||
yield* tx.run(
|
yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
|
||||||
`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(
|
yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_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 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_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_project_idx\` ON \`session\` (\`project_id\`);`)
|
||||||
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ import { CommandV2 } from "./command"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { PluginBoot } from "./plugin/boot"
|
import { PluginBoot } from "./plugin/boot"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
|
import { ProjectCopy } from "./project/copy"
|
||||||
|
import { ProjectDirectories } from "./project/directories"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Credential } from "./credential"
|
import { Credential } from "./credential"
|
||||||
import { Npm } from "./npm"
|
import { Npm } from "./npm"
|
||||||
import { ModelsDev } from "./models-dev"
|
import { ModelsDev } from "./models-dev"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
|
import { Git } from "./git"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { PermissionV2 } from "./permission"
|
import { PermissionV2 } from "./permission"
|
||||||
|
|
@ -63,6 +66,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||||
CommandV2.locationLayer,
|
CommandV2.locationLayer,
|
||||||
AgentV2.locationLayer,
|
AgentV2.locationLayer,
|
||||||
PluginBoot.locationLayer,
|
PluginBoot.locationLayer,
|
||||||
|
ProjectCopy.locationLayer,
|
||||||
FileSystem.locationLayer,
|
FileSystem.locationLayer,
|
||||||
Watcher.locationLayer,
|
Watcher.locationLayer,
|
||||||
Pty.locationLayer,
|
Pty.locationLayer,
|
||||||
|
|
@ -98,6 +102,11 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||||
Layer.provide(skillGuidance),
|
Layer.provide(skillGuidance),
|
||||||
Layer.provide(referenceGuidance),
|
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(
|
return Layer.mergeAll(
|
||||||
boot,
|
boot,
|
||||||
services,
|
services,
|
||||||
|
|
@ -110,6 +119,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||||
runner,
|
runner,
|
||||||
builtInTools,
|
builtInTools,
|
||||||
referenceGuidance,
|
referenceGuidance,
|
||||||
|
projectCopyRefresh,
|
||||||
).pipe(Layer.fresh)
|
).pipe(Layer.fresh)
|
||||||
},
|
},
|
||||||
idleTimeToLive: "60 minutes",
|
idleTimeToLive: "60 minutes",
|
||||||
|
|
@ -120,10 +130,12 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||||
Npm.defaultLayer,
|
Npm.defaultLayer,
|
||||||
ModelsDev.defaultLayer,
|
ModelsDev.defaultLayer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
|
Git.defaultLayer,
|
||||||
AppProcess.defaultLayer,
|
AppProcess.defaultLayer,
|
||||||
Global.defaultLayer,
|
Global.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
|
ProjectDirectories.defaultLayer,
|
||||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||||
PermissionSaved.defaultLayer,
|
PermissionSaved.defaultLayer,
|
||||||
RepositoryCache.defaultLayer,
|
RepositoryCache.defaultLayer,
|
||||||
|
|
|
||||||
|
|
@ -2,60 +2,41 @@ export * as ProjectV2 from "./project"
|
||||||
export * as Project from "./project"
|
export * as Project from "./project"
|
||||||
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { asc, desc, eq } from "drizzle-orm"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { AbsolutePath, withStatics } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Database } from "./database/database"
|
|
||||||
import { Git } from "./git"
|
import { Git } from "./git"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { LayerNode } from "./effect/layer-node"
|
||||||
import { Hash } from "./util/hash"
|
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(
|
export const ID = ProjectSchema.ID
|
||||||
Schema.brand("Project.ID"),
|
export type ID = ProjectSchema.ID
|
||||||
withStatics((schema) => ({
|
|
||||||
global: schema.make("global"),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
export type ID = typeof ID.Type
|
|
||||||
|
|
||||||
export const Vcs = Schema.Union([
|
export const Vcs = ProjectSchema.Vcs
|
||||||
Schema.Struct({
|
export type Vcs = ProjectSchema.Vcs
|
||||||
type: Schema.Literal("git"),
|
|
||||||
store: AbsolutePath,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
export type Vcs = typeof Vcs.Type
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Project.Info")({
|
export class Info extends Schema.Class<Info>("Project.Info")({
|
||||||
id: ID,
|
id: ID,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const DirectoriesInput = Schema.Struct({
|
export const DirectoriesInput = ProjectDirectories.ListInput
|
||||||
projectID: ID,
|
|
||||||
}).annotate({ identifier: "Project.DirectoriesInput" })
|
|
||||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||||
|
|
||||||
export const Directories = Schema.Array(
|
export const Directories = ProjectDirectories.ListOutput
|
||||||
Schema.Struct({
|
|
||||||
directory: AbsolutePath,
|
|
||||||
type: Schema.Literals(["main", "root", "git_worktree"]),
|
|
||||||
}),
|
|
||||||
).annotate({ identifier: "Project.Directories" })
|
|
||||||
export type Directories = typeof Directories.Type
|
export type Directories = typeof Directories.Type
|
||||||
|
|
||||||
|
export interface Resolved {
|
||||||
|
readonly previous?: ID
|
||||||
|
readonly id: ID
|
||||||
|
readonly directory: AbsolutePath
|
||||||
|
readonly vcs?: Vcs
|
||||||
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<
|
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||||
{
|
|
||||||
previous?: ID
|
|
||||||
id: ID
|
|
||||||
directory: AbsolutePath
|
|
||||||
vcs?: Vcs
|
|
||||||
},
|
|
||||||
never
|
|
||||||
>
|
|
||||||
/**
|
/**
|
||||||
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
||||||
*
|
*
|
||||||
|
|
@ -73,19 +54,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const db = (yield* Database.Service).db
|
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
|
const projectDirectories = yield* ProjectDirectories.Service
|
||||||
|
|
||||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||||
const rows = yield* db
|
return yield* projectDirectories.list(input.projectID)
|
||||||
.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 }))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||||
|
|
@ -156,8 +130,8 @@ export const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Database.defaultLayer),
|
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.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])
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { FSUtil } from "../fs-util"
|
|
||||||
import { Git } from "../git"
|
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
|
git: Git.Interface
|
||||||
fs: FSUtil.Interface
|
|
||||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||||
}) {
|
}) {
|
||||||
const repo = (sourceDirectory: AbsolutePath) =>
|
const repo = (sourceDirectory: AbsolutePath) =>
|
||||||
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
|
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
|
||||||
|
|
||||||
const gitWorktree: Strategy = {
|
return {
|
||||||
id: "git_worktree",
|
id: StrategyID.make("git_worktree"),
|
||||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||||
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
||||||
return { directory: yield* input.canonical(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 core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
|
||||||
const entries = yield* input.git.worktreeList(found)
|
const entries = yield* input.git.worktreeList(found)
|
||||||
return yield* Effect.forEach(entries, (entry) =>
|
return yield* Effect.forEach(entries, (entry) =>
|
||||||
entry === core
|
input.canonical(entry).pipe(
|
||||||
? Effect.succeed(undefined)
|
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
|
||||||
: input.canonical(entry).pipe(
|
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||||
Effect.map((directory) => ({ directory })),
|
),
|
||||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== 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"))
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
|
}),
|
||||||
|
} satisfies Strategy
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,29 @@
|
||||||
export * as ProjectCopy from "./copy"
|
export * as ProjectCopy from "./copy"
|
||||||
|
|
||||||
import { and, eq, inArray } from "drizzle-orm"
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Git } from "../git"
|
import { Git } from "../git"
|
||||||
import { Database } from "../database/database"
|
|
||||||
import { EventV2 } from "../event"
|
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { LayerNode } from "../effect/layer-node"
|
||||||
import { Project } from "../project"
|
import { Project } from "../project"
|
||||||
import { ProjectDirectoryTable } from "./sql"
|
import { ProjectDirectories } from "./directories"
|
||||||
import { makeStrategies } from "./copy-strategies"
|
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
||||||
import { Slug } from "../util/slug"
|
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 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({
|
export const CreateInput = Schema.Struct({
|
||||||
projectID: Project.ID,
|
projectID: Project.ID,
|
||||||
strategy: StrategyID,
|
strategy: StrategyID,
|
||||||
sourceDirectory: AbsolutePath,
|
sourceDirectory: AbsolutePath,
|
||||||
directory: AbsolutePath,
|
directory: AbsolutePath,
|
||||||
name: Schema.optional(Schema.String),
|
name: Schema.optional(Schema.String),
|
||||||
context: Schema.optional(Schema.String),
|
|
||||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||||
export type CreateInput = typeof CreateInput.Type
|
export type CreateInput = typeof CreateInput.Type
|
||||||
|
|
||||||
|
|
@ -44,12 +39,22 @@ export const RefreshInput = Schema.Struct({
|
||||||
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
|
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
|
||||||
export type RefreshInput = typeof RefreshInput.Type
|
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({
|
export const Copy = Schema.Struct({
|
||||||
directory: AbsolutePath,
|
directory: AbsolutePath,
|
||||||
}).annotate({ identifier: "ProjectCopy.Copy" })
|
}).annotate({ identifier: "ProjectCopy.Copy" })
|
||||||
export type Copy = typeof Copy.Type
|
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<SourceDirectoryNotFoundError>()(
|
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||||
"ProjectCopy.SourceDirectoryNotFoundError",
|
"ProjectCopy.SourceDirectoryNotFoundError",
|
||||||
|
|
@ -66,16 +71,27 @@ export class DirectoryUnavailableError extends Schema.TaggedErrorClass<Directory
|
||||||
{ directory: AbsolutePath },
|
{ directory: AbsolutePath },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
|
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||||
"ProjectCopy.StrategyNotFoundError",
|
"ProjectCopy.InvalidDirectoryError",
|
||||||
{ directory: AbsolutePath },
|
{ directory: AbsolutePath },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||||
|
"ProjectCopy.StrategyUnavailableError",
|
||||||
|
{ strategy: StrategyID },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
|
||||||
|
"ProjectCopy.DuplicateStrategyError",
|
||||||
|
{ strategy: StrategyID },
|
||||||
|
) {}
|
||||||
|
|
||||||
export type Error =
|
export type Error =
|
||||||
| SourceDirectoryNotFoundError
|
| SourceDirectoryNotFoundError
|
||||||
| DestinationExistsError
|
| DestinationExistsError
|
||||||
| DirectoryUnavailableError
|
| DirectoryUnavailableError
|
||||||
| StrategyNotFoundError
|
| InvalidDirectoryError
|
||||||
|
| StrategyUnavailableError
|
||||||
| Git.WorktreeError
|
| Git.WorktreeError
|
||||||
|
|
||||||
export interface Strategy {
|
export interface Strategy {
|
||||||
|
|
@ -88,8 +104,7 @@ export interface Strategy {
|
||||||
directory: AbsolutePath
|
directory: AbsolutePath
|
||||||
force: boolean
|
force: boolean
|
||||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||||
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
|
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||||
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
|
|
@ -100,21 +115,46 @@ export const Event = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
|
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
|
||||||
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
||||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||||
readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
|
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
|
export class Service extends Context.Service<Service, Interface>()("@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(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
const events = yield* EventV2.Service
|
const directories = yield* ProjectDirectories.Service
|
||||||
const db = (yield* Database.Service).db
|
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 canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||||
const resolved = AbsolutePath.make(FSUtil.resolve(input))
|
const resolved = AbsolutePath.make(FSUtil.resolve(input))
|
||||||
|
|
@ -122,76 +162,34 @@ export const layer = Layer.effect(
|
||||||
return resolved
|
return resolved
|
||||||
})
|
})
|
||||||
|
|
||||||
const registry = makeStrategies({ git, fs, canonical })
|
const registry = new Map<StrategyID, Strategy>()
|
||||||
|
|
||||||
|
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 source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
|
||||||
const sourceDirectory = yield* canonical(input)
|
const sourceDirectory = yield* canonical(input)
|
||||||
const row = yield* db
|
if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
|
||||||
.select({ directory: ProjectDirectoryTable.directory })
|
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
|
||||||
.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 })
|
|
||||||
return sourceDirectory
|
return sourceDirectory
|
||||||
})
|
})
|
||||||
|
|
||||||
const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath, type: StrategyID) {
|
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||||
return yield* db
|
const found = registry.get(id)
|
||||||
.transaction(
|
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||||
(tx) =>
|
return found
|
||||||
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 create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
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)
|
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||||
const name = input.name ?? Slug.create()
|
const name = input.name ?? Slug.create()
|
||||||
let suffix = 1
|
let suffix = 1
|
||||||
|
|
@ -202,78 +200,100 @@ export const layer = Layer.effect(
|
||||||
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* strategy(input.strategy).create({
|
const result = yield* selected.create({
|
||||||
directory: copyDirectory,
|
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
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
|
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
|
||||||
const copyDirectory = yield* canonical(input.directory)
|
const copyDirectory = yield* canonical(input.directory)
|
||||||
const id = yield* detect({ directory: copyDirectory })
|
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
|
||||||
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
|
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
|
||||||
yield* strategy(id).remove({ directory: copyDirectory, force: input.force })
|
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
|
||||||
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
|
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 refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
|
||||||
const roots = yield* db
|
const stored = yield* directories.list(input.projectID)
|
||||||
.select({ directory: ProjectDirectoryTable.directory })
|
const checked = yield* Effect.forEach(
|
||||||
.from(ProjectDirectoryTable)
|
stored,
|
||||||
.where(
|
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||||
and(
|
{ concurrency: "unbounded" },
|
||||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
)
|
||||||
inArray(ProjectDirectoryTable.type, ["main", "root"]),
|
const sourceDirectories = checked
|
||||||
),
|
.filter((item) => item.strategy === undefined && item.exists)
|
||||||
)
|
.map((item) => item.directory)
|
||||||
.all()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const sourceDirectories = yield* Effect.forEach(roots, (item) => canonical(AbsolutePath.make(item.directory)), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
})
|
|
||||||
const discovered = yield* Effect.forEach(
|
const discovered = yield* Effect.forEach(
|
||||||
sourceDirectories,
|
sourceDirectories,
|
||||||
(sourceDirectory) =>
|
(sourceDirectory) =>
|
||||||
Effect.forEach(registry.values(), (strategy) =>
|
Effect.forEach(strategies(), (strategy) =>
|
||||||
strategy
|
strategy.list(sourceDirectory).pipe(
|
||||||
.list(sourceDirectory)
|
Effect.map((items) =>
|
||||||
.pipe(Effect.map((items) => items.map((item) => ({ ...item, type: strategy.id })))),
|
items.map((item) => ({
|
||||||
|
directory: item.directory,
|
||||||
|
strategy: item.type === "copy" ? strategy.id : undefined,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
|
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
|
||||||
)
|
)
|
||||||
const stored = yield* db
|
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||||
.select({ directory: ProjectDirectoryTable.directory })
|
const result = yield* db
|
||||||
.from(ProjectDirectoryTable)
|
.transaction((tx) =>
|
||||||
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
|
Effect.all({
|
||||||
.all()
|
updated: Effect.forEach(discovered, (item) =>
|
||||||
.pipe(Effect.orDie)
|
directories.create(
|
||||||
const inserted = yield* Effect.forEach(discovered, (item) =>
|
{
|
||||||
insert(input.projectID, item.directory, item.type),
|
projectID: input.projectID,
|
||||||
).pipe(Effect.map((items) => items.some(Boolean)))
|
directory: item.directory,
|
||||||
const removed = yield* Effect.forEach(stored, (item) =>
|
strategy: item.strategy,
|
||||||
fs
|
behavior: "replace",
|
||||||
.isDir(item.directory)
|
},
|
||||||
.pipe(
|
tx,
|
||||||
Effect.flatMap((exists) =>
|
),
|
||||||
exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.directory)),
|
|
||||||
),
|
),
|
||||||
),
|
removed: Effect.forEach(removed, (directory) =>
|
||||||
).pipe(Effect.map((items) => items.some(Boolean)))
|
directories.remove({ projectID: input.projectID, directory }, tx),
|
||||||
yield* changed(input.projectID, inserted || removed)
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.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(
|
export const locationLayer = layer
|
||||||
Layer.provide(Database.defaultLayer),
|
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
|
||||||
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])
|
|
||||||
|
|
|
||||||
162
packages/core/src/project/directories.ts
Normal file
162
packages/core/src/project/directories.ts
Normal file
|
|
@ -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<Parameters<DatabaseClient["transaction"]>[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<ReadonlyArray<Directory>>
|
||||||
|
readonly get: (input: {
|
||||||
|
projectID: ProjectSchema.ID
|
||||||
|
directory: AbsolutePath
|
||||||
|
}) => Effect.Effect<Directory | undefined>
|
||||||
|
readonly contains: (input: { projectID: ProjectSchema.ID; directory: AbsolutePath }) => Effect.Effect<boolean>
|
||||||
|
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||||
|
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@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])
|
||||||
20
packages/core/src/project/schema.ts
Normal file
20
packages/core/src/project/schema.ts
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||||
import * as DatabasePath from "../database/path"
|
import * as DatabasePath from "../database/path"
|
||||||
import { Timestamps } from "../database/schema.sql"
|
import { Timestamps } from "../database/schema.sql"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectSchema } from "./schema"
|
||||||
|
|
||||||
export const ProjectTable = sqliteTable("project", {
|
export const ProjectTable = sqliteTable("project", {
|
||||||
id: text().$type<ProjectV2.ID>().primaryKey(),
|
id: text().$type<ProjectSchema.ID>().primaryKey(),
|
||||||
worktree: DatabasePath.absoluteColumn().notNull(),
|
worktree: DatabasePath.absoluteColumn().notNull(),
|
||||||
vcs: text(),
|
vcs: text(),
|
||||||
name: text(),
|
name: text(),
|
||||||
|
|
@ -21,11 +21,12 @@ export const ProjectDirectoryTable = sqliteTable(
|
||||||
"project_directory",
|
"project_directory",
|
||||||
{
|
{
|
||||||
project_id: text()
|
project_id: text()
|
||||||
.$type<ProjectV2.ID>()
|
.$type<ProjectSchema.ID>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||||
directory: text().notNull(),
|
directory: DatabasePath.absoluteColumn().notNull(),
|
||||||
type: text().$type<"main" | "root" | "git_worktree">().notNull(),
|
type: text().$type<"main" | "root" | "git_worktree">(),
|
||||||
|
strategy: text(),
|
||||||
time_created: integer()
|
time_created: integer()
|
||||||
.notNull()
|
.notNull()
|
||||||
.$default(() => Date.now()),
|
.$default(() => Date.now()),
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { ApplicationTools } from "../src/tool/application-tools"
|
||||||
const applicationTools = ApplicationTools.layer
|
const applicationTools = ApplicationTools.layer
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.merge(
|
Layer.merge(
|
||||||
applicationTools,
|
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
||||||
LocationServiceMap.layer.pipe(
|
LocationServiceMap.layer.pipe(
|
||||||
Layer.provide(applicationTools),
|
Layer.provide(applicationTools),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
|
|
@ -135,4 +135,5 @@ describe("LocationServiceMap", () => {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Git } from "@opencode-ai/core/git"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
|
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
|
@ -22,11 +23,13 @@ import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const database = Database.layerFromPath(":memory:")
|
const database = Database.layerFromPath(":memory:")
|
||||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
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 projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||||
const project = Project.layer.pipe(
|
const project = Project.layer.pipe(
|
||||||
Layer.provide(database),
|
Layer.provide(database),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(directories),
|
||||||
)
|
)
|
||||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||||
const sessions = SessionV2.layer.pipe(
|
const sessions = SessionV2.layer.pipe(
|
||||||
|
|
@ -45,7 +48,7 @@ const layer = MoveSession.layer.pipe(
|
||||||
Layer.provide(sessions),
|
Layer.provide(sessions),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
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) {
|
function abs(input: string) {
|
||||||
|
|
|
||||||
|
|
@ -12,23 +12,28 @@ import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||||
|
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const databaseLayer = Database.layerFromPath(":memory:")
|
const databaseLayer = Database.layerFromPath(":memory:")
|
||||||
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
||||||
|
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
||||||
const copyLayer = ProjectCopy.layer.pipe(
|
const copyLayer = ProjectCopy.layer.pipe(
|
||||||
Layer.provide(databaseLayer),
|
Layer.provide(databaseLayer),
|
||||||
|
Layer.provide(directoriesLayer),
|
||||||
Layer.provide(eventLayer),
|
Layer.provide(eventLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.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) {
|
function abs(input: string) {
|
||||||
return AbsolutePath.make(input)
|
return AbsolutePath.make(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
|
||||||
|
|
||||||
async function initRepo(directory: string) {
|
async function initRepo(directory: string) {
|
||||||
await $`git init`.cwd(directory).quiet()
|
await $`git init`.cwd(directory).quiet()
|
||||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||||
|
|
@ -55,7 +60,7 @@ function setup() {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* db
|
yield* db
|
||||||
.insert(ProjectDirectoryTable)
|
.insert(ProjectDirectoryTable)
|
||||||
.values({ project_id: projectID, directory: sourceDirectory, type: "main" })
|
.values({ project_id: projectID, directory: sourceDirectory })
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return { root, sourceDirectory, projectID, db }
|
return { root, sourceDirectory, projectID, db }
|
||||||
|
|
@ -65,7 +70,7 @@ function setup() {
|
||||||
function stored(projectID: Project.ID) {
|
function stored(projectID: Project.ID) {
|
||||||
return Database.Service.use(({ db }) =>
|
return Database.Service.use(({ db }) =>
|
||||||
db
|
db
|
||||||
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
|
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||||
.from(ProjectDirectoryTable)
|
.from(ProjectDirectoryTable)
|
||||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -77,18 +82,40 @@ function stored(projectID: Project.ID) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectCopy", () => {
|
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* () {
|
Effect.gen(function* () {
|
||||||
const input = yield* setup()
|
const input = yield* setup()
|
||||||
const copy = yield* ProjectCopy.Service
|
const copy = yield* ProjectCopy.Service
|
||||||
const target = abs(`${input.root.path}-copy-detected`)
|
const strategy: ProjectCopy.Strategy = {
|
||||||
yield* Effect.addFinalizer(() =>
|
id: ProjectCopy.StrategyID.make("test/duplicate"),
|
||||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
create: () => Effect.die("unused"),
|
||||||
)
|
remove: () => Effect.die("unused"),
|
||||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
list: () => Effect.succeed([]),
|
||||||
|
}
|
||||||
|
|
||||||
expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
|
yield* copy.register(strategy)
|
||||||
expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
|
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({
|
const created = yield* copy.create({
|
||||||
projectID: input.projectID,
|
projectID: input.projectID,
|
||||||
strategy: "git_worktree",
|
strategy: gitWorktree,
|
||||||
sourceDirectory: input.sourceDirectory,
|
sourceDirectory: input.sourceDirectory,
|
||||||
directory: parent,
|
directory: parent,
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
|
@ -118,15 +145,15 @@ describe("ProjectCopy", () => {
|
||||||
expect(created.directory).toBe(target)
|
expect(created.directory).toBe(target)
|
||||||
expect(yield* stored(input.projectID)).toEqual(
|
expect(yield* stored(input.projectID)).toEqual(
|
||||||
[
|
[
|
||||||
{ directory: input.sourceDirectory, type: "main" as const },
|
{ directory: input.sourceDirectory, strategy: null },
|
||||||
{ directory: created.directory, type: "git_worktree" as const },
|
{ directory: created.directory, strategy: "git_worktree" },
|
||||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||||
)
|
)
|
||||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||||
|
|
||||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
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)
|
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -142,7 +169,7 @@ describe("ProjectCopy", () => {
|
||||||
)
|
)
|
||||||
const created = yield* copy.create({
|
const created = yield* copy.create({
|
||||||
projectID: input.projectID,
|
projectID: input.projectID,
|
||||||
strategy: "git_worktree",
|
strategy: gitWorktree,
|
||||||
sourceDirectory: input.sourceDirectory,
|
sourceDirectory: input.sourceDirectory,
|
||||||
directory: parent,
|
directory: parent,
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
|
@ -158,7 +185,7 @@ describe("ProjectCopy", () => {
|
||||||
expect(error.operation).toBe("remove")
|
expect(error.operation).toBe("remove")
|
||||||
expect(error.forceRequired).toBe(true)
|
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)
|
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 })
|
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", () =>
|
it.live("adds a numeric suffix when a copy directory already exists", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const input = yield* setup()
|
const input = yield* setup()
|
||||||
|
|
@ -181,7 +230,7 @@ describe("ProjectCopy", () => {
|
||||||
|
|
||||||
const created = yield* copy.create({
|
const created = yield* copy.create({
|
||||||
projectID: input.projectID,
|
projectID: input.projectID,
|
||||||
strategy: "git_worktree",
|
strategy: gitWorktree,
|
||||||
sourceDirectory: input.sourceDirectory,
|
sourceDirectory: input.sourceDirectory,
|
||||||
directory: parent,
|
directory: parent,
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
|
@ -219,7 +268,7 @@ describe("ProjectCopy", () => {
|
||||||
const error = yield* copy
|
const error = yield* copy
|
||||||
.create({
|
.create({
|
||||||
projectID: input.projectID,
|
projectID: input.projectID,
|
||||||
strategy: "git_worktree",
|
strategy: gitWorktree,
|
||||||
sourceDirectory: input.sourceDirectory,
|
sourceDirectory: input.sourceDirectory,
|
||||||
directory: parent,
|
directory: parent,
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
|
@ -227,7 +276,8 @@ describe("ProjectCopy", () => {
|
||||||
.pipe(Effect.flip)
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
|
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),
|
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* 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
|
const fiber = yield* events
|
||||||
.subscribe(ProjectCopy.Event.Updated)
|
.subscribe(ProjectCopy.Event.Updated)
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* copy.refresh({ projectID: input.projectID })
|
|
||||||
|
|
||||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
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(
|
expect(yield* stored(input.projectID)).toEqual(
|
||||||
[
|
[
|
||||||
{ directory: input.sourceDirectory, type: "main" as const },
|
{ directory: input.sourceDirectory, strategy: null },
|
||||||
{ directory: discovered, type: "git_worktree" as const },
|
{ directory: discovered, strategy: "git_worktree" },
|
||||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||||
)
|
)
|
||||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
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* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||||
yield* copy.refresh({ projectID: input.projectID })
|
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
|
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)))
|
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||||
expect(yield* stored(input.projectID)).toEqual(
|
expect(yield* stored(input.projectID)).toEqual(
|
||||||
[
|
[
|
||||||
{ directory: input.sourceDirectory, type: "main" as const },
|
{ directory: input.sourceDirectory, strategy: null },
|
||||||
{ directory: discovered, type: "git_worktree" as const },
|
{ directory: discovered, strategy: "git_worktree" },
|
||||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
@ -314,7 +369,27 @@ describe("ProjectCopy", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const copy = yield* ProjectCopy.Service
|
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 })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
69
packages/core/test/project-directories.test.ts
Normal file
69
packages/core/test/project-directories.test.ts
Normal file
|
|
@ -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" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -4,24 +4,27 @@ import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
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 { Database } from "@opencode-ai/core/database/database"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Git } from "@opencode-ai/core/git"
|
import { Git } from "@opencode-ai/core/git"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
|
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const databaseLayer = Database.layerFromPath(":memory:")
|
const databaseLayer = Database.layerFromPath(":memory:")
|
||||||
|
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
ProjectV2.layer.pipe(
|
ProjectV2.layer.pipe(
|
||||||
Layer.provide(databaseLayer),
|
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(directoriesLayer),
|
||||||
|
Layer.provide(databaseLayer),
|
||||||
),
|
),
|
||||||
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()
|
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", () => {
|
describe("ProjectV2.resolve", () => {
|
||||||
it.live("returns global for non-git directory", () =>
|
it.live("returns global for non-git directory", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { and, eq, sql } from "drizzle-orm"
|
import { and, eq, sql } from "drizzle-orm"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
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 { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
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 { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||||
|
|
@ -138,7 +138,7 @@ export const layer = Layer.effect(
|
||||||
const proc = yield* AppProcess.Service
|
const proc = yield* AppProcess.Service
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const projectV2 = yield* ProjectV2.Service
|
const projectV2 = yield* ProjectV2.Service
|
||||||
const projectCopy = yield* ProjectCopy.Service
|
const projectDirectories = yield* ProjectDirectories.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
@ -197,6 +197,12 @@ export const layer = Layer.effect(
|
||||||
.run()
|
.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
|
yield* d
|
||||||
.update(SessionTable)
|
.update(SessionTable)
|
||||||
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
|
.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
|
if (input.projectID === ProjectV2.ID.global) return
|
||||||
const opened = AbsolutePath.make(FSUtil.resolve(input.directory))
|
const opened = AbsolutePath.make(FSUtil.resolve(input.directory))
|
||||||
const type = yield* projectCopy.detect({ directory: opened })
|
yield* projectDirectories
|
||||||
|
.create({
|
||||||
yield* db
|
directory: opened,
|
||||||
.transaction(
|
projectID: input.projectID,
|
||||||
(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" },
|
|
||||||
)
|
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchCause((cause) =>
|
Effect.catchCause((cause) =>
|
||||||
Effect.logWarning("project directory persistence failed", { projectID: input.projectID, 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(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.provide(ProjectCopy.defaultLayer),
|
Layer.provide(ProjectDirectories.defaultLayer),
|
||||||
Layer.provide(AppProcess.defaultLayer),
|
Layer.provide(AppProcess.defaultLayer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
|
|
@ -520,7 +510,7 @@ export const node = LayerNode.make(layer, [
|
||||||
AppProcess.node,
|
AppProcess.node,
|
||||||
CrossSpawnSpawner.node,
|
CrossSpawnSpawner.node,
|
||||||
ProjectV2.node,
|
ProjectV2.node,
|
||||||
ProjectCopy.node,
|
ProjectDirectories.node,
|
||||||
EventV2Bridge.node,
|
EventV2Bridge.node,
|
||||||
RuntimeFlags.node,
|
RuntimeFlags.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,31 @@
|
||||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { Schema } from "effect"
|
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 { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import {
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||||
WorkspaceRoutingMiddleware,
|
|
||||||
WorkspaceRoutingQuery,
|
|
||||||
WorkspaceRoutingQueryFields,
|
|
||||||
} from "../middleware/workspace-routing"
|
|
||||||
import { described } from "./metadata"
|
|
||||||
|
|
||||||
const root = "/experimental/project/:projectID/copy"
|
export const GenerateNamePayload = Schema.Struct({
|
||||||
const CopyQuery = Schema.Struct({
|
context: Schema.optional(Schema.String),
|
||||||
workspace: WorkspaceRoutingQueryFields.workspace,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const CreatePayload = Schema.Struct({
|
export const ProjectCopyApi = HttpApi.make("projectCopyName").add(
|
||||||
strategy: ProjectCopy.StrategyID,
|
HttpApiGroup.make("projectCopyName")
|
||||||
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<ApiProjectCopyError>("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")
|
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("create", root, {
|
HttpApiEndpoint.post("generateName", "/experimental/project/:projectID/copy/generate-name", {
|
||||||
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`, {
|
|
||||||
params: { projectID: ProjectV2.ID },
|
params: { projectID: ProjectV2.ID },
|
||||||
query: WorkspaceRoutingQuery,
|
query: WorkspaceRoutingQuery,
|
||||||
payload: HttpApiSchema.NoContent,
|
payload: GenerateNamePayload,
|
||||||
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
|
success: Schema.Struct({ name: Schema.String }),
|
||||||
error: ApiProjectCopyError,
|
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.projectCopy.refresh",
|
identifier: "experimental.projectCopy.generateName",
|
||||||
summary: "Refresh project copies",
|
summary: "Generate project copy name",
|
||||||
description: "Discover local project copies using one or all configured strategies.",
|
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(InstanceContextMiddleware)
|
||||||
.middleware(WorkspaceRoutingMiddleware)
|
.middleware(WorkspaceRoutingMiddleware)
|
||||||
.middleware(Authorization),
|
.middleware(Authorization),
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,45 @@
|
||||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Git } from "@opencode-ai/core/git"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { LLM } from "@/session/llm"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { Slug } from "@opencode-ai/core/util/slug"
|
||||||
|
import { LLMEvent } from "@opencode-ai/llm"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
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 = {
|
const COPY_NAME_AGENT: Agent.Info = {
|
||||||
name: "title",
|
name: "project-copy-name",
|
||||||
mode: "primary" as const,
|
mode: "primary",
|
||||||
permission: [],
|
permission: [],
|
||||||
options: {},
|
options: {},
|
||||||
native: true,
|
native: true,
|
||||||
prompt: "",
|
prompt: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopyName", (handlers) =>
|
||||||
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) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const llm = yield* LLM.Service
|
const llm = yield* LLM.Service
|
||||||
const agent = yield* Agent.Service
|
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const service = yield* ProjectCopy.Service
|
|
||||||
|
|
||||||
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
|
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
|
||||||
const text = context?.trim()
|
const text = context?.trim()
|
||||||
if (!text) return Slug.create()
|
if (!text) return Slug.create()
|
||||||
const [titleAgent, fallback] = yield* Effect.all(
|
const fallback = yield* provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
[
|
|
||||||
agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
|
|
||||||
provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
if (!fallback) return Slug.create()
|
if (!fallback) return Slug.create()
|
||||||
const model = titleAgent.model
|
const model =
|
||||||
? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
|
(yield* provider.getSmallModel(fallback.providerID)) ??
|
||||||
: ((yield* provider.getSmallModel(fallback.providerID)) ??
|
(yield* provider.getModel(fallback.providerID, fallback.modelID))
|
||||||
(yield* provider.getModel(fallback.providerID, fallback.modelID)))
|
|
||||||
const sessionID = SessionID.descending()
|
const sessionID = SessionID.descending()
|
||||||
const result = yield* llm
|
const result = yield* llm
|
||||||
.stream({
|
.stream({
|
||||||
agent: titleAgent,
|
agent: COPY_NAME_AGENT,
|
||||||
user: {
|
user: {
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
sessionID,
|
sessionID,
|
||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: titleAgent.name,
|
agent: COPY_NAME_AGENT.name,
|
||||||
model: { providerID: model.providerID, modelID: model.id },
|
model: { providerID: model.providerID, modelID: model.id },
|
||||||
},
|
},
|
||||||
system: [],
|
system: [],
|
||||||
|
|
@ -78,12 +48,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
||||||
model,
|
model,
|
||||||
sessionID,
|
sessionID,
|
||||||
retries: 2,
|
retries: 2,
|
||||||
messages: [
|
messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }],
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: `Generate a short 3-4 word name that describes this task:\n${text}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.filter(LLMEvent.is.textDelta),
|
Stream.filter(LLMEvent.is.textDelta),
|
||||||
|
|
@ -91,47 +56,20 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
||||||
Stream.mkString,
|
Stream.mkString,
|
||||||
)
|
)
|
||||||
const output = result.trim()
|
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: {
|
return handlers.handle("generateName", (ctx) =>
|
||||||
params: { projectID: ProjectV2.ID }
|
generateName(ctx.payload.context).pipe(
|
||||||
payload: typeof CreatePayload.Type
|
Effect.catchCause((cause) =>
|
||||||
}) {
|
Effect.logWarning("project copy name generation failed", {
|
||||||
const name =
|
projectID: ctx.params.projectID,
|
||||||
ctx.payload.name ??
|
cause,
|
||||||
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
|
}).pipe(Effect.as(Slug.create())),
|
||||||
return yield* badRequest(
|
),
|
||||||
service.create({
|
Effect.map((name) => ({ name })),
|
||||||
...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)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -143,15 +81,3 @@ function slugify(input: string) {
|
||||||
.replace(/^-+/, "")
|
.replace(/^-+/, "")
|
||||||
.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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ function directories(projectID: ProjectV2.ID) {
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
Effect.map((rows) =>
|
Effect.map((rows) =>
|
||||||
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)),
|
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -41,7 +41,9 @@ describe("Project directory persistence", () => {
|
||||||
|
|
||||||
const result = yield* project.fromDirectory(tmp)
|
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)
|
const next = yield* project.fromDirectory(tmp)
|
||||||
|
|
||||||
expect(next.project.id).toBe(result.project.id)
|
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(
|
expect(yield* directories(main.project.id)).toEqual(
|
||||||
[
|
[
|
||||||
{ directory: tmp, type: "main" as const },
|
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||||
{ directory: worktree, type: "git_worktree" as const },
|
{ directory: AbsolutePath.make(worktree), strategy: undefined },
|
||||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
@ -92,7 +96,9 @@ describe("Project directory persistence", () => {
|
||||||
|
|
||||||
const result = yield* project.fromDirectory(worktree)
|
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(
|
expect(yield* directories(main.project.id)).toEqual(
|
||||||
[
|
[
|
||||||
{ directory: tmp, type: "main" as const },
|
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||||
{ directory: clone, type: "root" as const },
|
{ directory: AbsolutePath.make(clone), strategy: undefined },
|
||||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
@ -134,7 +140,9 @@ describe("Project directory persistence", () => {
|
||||||
|
|
||||||
const result = yield* project.fromDirectory(worktree)
|
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)
|
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 },
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { NodePath } from "@effect/platform-node"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
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(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
|
||||||
Layer.provide(mockGitFailure(failArg)),
|
Layer.provide(mockGitFailure(failArg)),
|
||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.provide(ProjectCopy.defaultLayer),
|
Layer.provide(ProjectDirectories.defaultLayer),
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
|
|
@ -86,7 +86,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
|
||||||
return Project.layer.pipe(
|
return Project.layer.pipe(
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(ProjectV2.defaultLayer),
|
Layer.provide(ProjectV2.defaultLayer),
|
||||||
Layer.provide(ProjectCopy.defaultLayer),
|
Layer.provide(ProjectDirectories.defaultLayer),
|
||||||
Layer.provide(AppProcess.defaultLayer),
|
Layer.provide(AppProcess.defaultLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,18 @@ const scenarios: Scenario[] = [
|
||||||
headers: ctx.headers(),
|
headers: ctx.headers(),
|
||||||
}))
|
}))
|
||||||
.json(200, array, "status"),
|
.json(200, array, "status"),
|
||||||
|
http.protected
|
||||||
|
.post("/experimental/project/{projectID}/copy/generate-name", "experimental.projectCopy.generateName")
|
||||||
|
.seeded((ctx) => 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
|
http.protected
|
||||||
.post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
|
.post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
|
||||||
.seeded((ctx) => ctx.project())
|
.seeded((ctx) => ctx.project())
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("project directories and copies endpoints", () => {
|
describe("project directories and copies endpoints", () => {
|
||||||
type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" }
|
type ProjectDirectory = { directory: string; strategy?: string }
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"lists directories and manages git worktree copies",
|
"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 current = yield* request(test.directory, "/project/current")
|
||||||
const projectID = (yield* json<{ id: string }>(current)).id
|
const projectID = (yield* json<{ id: string }>(current)).id
|
||||||
const base = `/project/${projectID}`
|
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 createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
|
||||||
const createdDirectory = path.join(createdParent, "copy")
|
const createdDirectory = path.join(createdParent, "copy")
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
|
|
@ -53,7 +53,15 @@ describe("project directories and copies endpoints", () => {
|
||||||
|
|
||||||
const initial = yield* request(test.directory, `${base}/directories`)
|
const initial = yield* request(test.directory, `${base}/directories`)
|
||||||
expect(initial.status).toBe(200)
|
expect(initial.status).toBe(200)
|
||||||
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory, type: "main" }])
|
expect(yield* json<ProjectDirectory[]>(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, {
|
const create = yield* request(test.directory, copies, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -67,7 +75,7 @@ describe("project directories and copies endpoints", () => {
|
||||||
const listed = yield* request(test.directory, `${base}/directories`)
|
const listed = yield* request(test.directory, `${base}/directories`)
|
||||||
expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
|
expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
|
||||||
directory: created.directory,
|
directory: created.directory,
|
||||||
type: "git_worktree",
|
strategy: "git_worktree",
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
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),
|
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())
|
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",
|
method: "POST",
|
||||||
})
|
},
|
||||||
|
)
|
||||||
expect(refresh.status).toBe(204)
|
expect(refresh.status).toBe(204)
|
||||||
const refreshed = yield* request(test.directory, `${base}/directories`)
|
const refreshed = yield* request(test.directory, `${base}/directories`)
|
||||||
expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
|
expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
|
||||||
{ directory: externalDirectory, type: "git_worktree" },
|
{ directory: externalDirectory, strategy: "git_worktree" },
|
||||||
{ directory: test.directory, type: "main" },
|
{ directory: test.directory },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true },
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,8 @@ import type {
|
||||||
ExperimentalConsoleSwitchOrgResponses,
|
ExperimentalConsoleSwitchOrgResponses,
|
||||||
ExperimentalControlPlaneMoveSessionErrors,
|
ExperimentalControlPlaneMoveSessionErrors,
|
||||||
ExperimentalControlPlaneMoveSessionResponses,
|
ExperimentalControlPlaneMoveSessionResponses,
|
||||||
ExperimentalProjectCopyCreateErrors,
|
ExperimentalProjectCopyGenerateNameErrors,
|
||||||
ExperimentalProjectCopyCreateResponses,
|
ExperimentalProjectCopyGenerateNameResponses,
|
||||||
ExperimentalProjectCopyRefreshErrors,
|
|
||||||
ExperimentalProjectCopyRefreshResponses,
|
|
||||||
ExperimentalProjectCopyRemoveErrors,
|
|
||||||
ExperimentalProjectCopyRemoveResponses,
|
|
||||||
ExperimentalResourceListErrors,
|
ExperimentalResourceListErrors,
|
||||||
ExperimentalResourceListResponses,
|
ExperimentalResourceListResponses,
|
||||||
ExperimentalSessionBackgroundErrors,
|
ExperimentalSessionBackgroundErrors,
|
||||||
|
|
@ -303,6 +299,12 @@ import type {
|
||||||
V2PermissionSavedListResponses,
|
V2PermissionSavedListResponses,
|
||||||
V2PermissionSavedRemoveErrors,
|
V2PermissionSavedRemoveErrors,
|
||||||
V2PermissionSavedRemoveResponses,
|
V2PermissionSavedRemoveResponses,
|
||||||
|
V2ProjectCopyCreateErrors,
|
||||||
|
V2ProjectCopyCreateResponses,
|
||||||
|
V2ProjectCopyRefreshErrors,
|
||||||
|
V2ProjectCopyRefreshResponses,
|
||||||
|
V2ProjectCopyRemoveErrors,
|
||||||
|
V2ProjectCopyRemoveResponses,
|
||||||
V2ProviderGetErrors,
|
V2ProviderGetErrors,
|
||||||
V2ProviderGetResponses,
|
V2ProviderGetResponses,
|
||||||
V2ProviderListErrors,
|
V2ProviderListErrors,
|
||||||
|
|
@ -842,107 +844,18 @@ export class Resource extends HeyApiClient {
|
||||||
|
|
||||||
export class ProjectCopy 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<ThrowOnError extends boolean = false>(
|
public generateName<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
projectID: string
|
projectID: string
|
||||||
workspace?: string
|
|
||||||
directory?: string
|
directory?: string
|
||||||
force?: boolean
|
|
||||||
},
|
|
||||||
options?: Options<never, ThrowOnError>,
|
|
||||||
) {
|
|
||||||
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<ThrowOnError extends boolean = false>(
|
|
||||||
parameters: {
|
|
||||||
projectID: string
|
|
||||||
workspace?: string
|
workspace?: string
|
||||||
strategy?: "git_worktree"
|
|
||||||
directory?: string
|
|
||||||
name?: string
|
|
||||||
context?: string
|
context?: string
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
|
||||||
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<ThrowOnError extends boolean = false>(
|
|
||||||
parameters: {
|
|
||||||
projectID: string
|
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
|
||||||
},
|
|
||||||
options?: Options<never, ThrowOnError>,
|
|
||||||
) {
|
) {
|
||||||
const params = buildClientParams(
|
const params = buildClientParams(
|
||||||
[parameters],
|
[parameters],
|
||||||
|
|
@ -952,18 +865,24 @@ export class ProjectCopy extends HeyApiClient {
|
||||||
{ in: "path", key: "projectID" },
|
{ in: "path", key: "projectID" },
|
||||||
{ in: "query", key: "directory" },
|
{ in: "query", key: "directory" },
|
||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
|
{ in: "body", key: "context" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (options?.client ?? this.client).post<
|
return (options?.client ?? this.client).post<
|
||||||
ExperimentalProjectCopyRefreshResponses,
|
ExperimentalProjectCopyGenerateNameResponses,
|
||||||
ExperimentalProjectCopyRefreshErrors,
|
ExperimentalProjectCopyGenerateNameErrors,
|
||||||
ThrowOnError
|
ThrowOnError
|
||||||
>({
|
>({
|
||||||
url: "/experimental/project/{projectID}/copy/refresh",
|
url: "/experimental/project/{projectID}/copy/generate-name",
|
||||||
...options,
|
...options,
|
||||||
...params,
|
...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<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
projectID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
directory?: string
|
||||||
|
force?: boolean
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
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<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
projectID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
strategy?: string
|
||||||
|
directory?: string
|
||||||
|
name?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
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<V2ProjectCopyCreateResponses, V2ProjectCopyCreateErrors, ThrowOnError>(
|
||||||
|
{
|
||||||
|
url: "/experimental/project/{projectID}/copy",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public refresh<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
projectID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
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 {
|
export class V2 extends HeyApiClient {
|
||||||
private _health?: Health
|
private _health?: Health
|
||||||
get health(): Health {
|
get health(): Health {
|
||||||
|
|
@ -6316,6 +6351,11 @@ export class V2 extends HeyApiClient {
|
||||||
get reference(): Reference {
|
get reference(): Reference {
|
||||||
return (this._reference ??= new Reference({ client: this.client }))
|
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 {
|
export class OpencodeClient extends HeyApiClient {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export type Event =
|
||||||
| EventPermissionV2Asked
|
| EventPermissionV2Asked
|
||||||
| EventPermissionV2Replied
|
| EventPermissionV2Replied
|
||||||
| EventReferenceUpdated
|
| EventReferenceUpdated
|
||||||
|
| EventProjectDirectoriesUpdated
|
||||||
| EventFileWatcherUpdated
|
| EventFileWatcherUpdated
|
||||||
| EventPtyCreated
|
| EventPtyCreated
|
||||||
| EventPtyUpdated
|
| EventPtyUpdated
|
||||||
|
|
@ -75,7 +76,6 @@ export type Event =
|
||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
| EventMcpBrowserOpenFailed
|
| EventMcpBrowserOpenFailed
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
| EventProjectDirectoriesUpdated
|
|
||||||
| EventProjectUpdated
|
| EventProjectUpdated
|
||||||
| EventSessionStatus
|
| EventSessionStatus
|
||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
|
|
@ -1294,6 +1294,13 @@ export type GlobalEvent = {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "project.directories.updated"
|
||||||
|
properties: {
|
||||||
|
projectID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "file.watcher.updated"
|
type: "file.watcher.updated"
|
||||||
|
|
@ -1479,13 +1486,6 @@ export type GlobalEvent = {
|
||||||
messageID: string
|
messageID: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
type: "project.directories.updated"
|
|
||||||
properties: {
|
|
||||||
projectID: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "project.updated"
|
type: "project.updated"
|
||||||
|
|
@ -2464,14 +2464,6 @@ export type ProjectNotFoundError = {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectCopyError = {
|
|
||||||
name: "ProjectCopyError"
|
|
||||||
data: {
|
|
||||||
message: string
|
|
||||||
forceRequired?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PtyNotFoundError = {
|
export type PtyNotFoundError = {
|
||||||
_tag: "PtyNotFoundError"
|
_tag: "PtyNotFoundError"
|
||||||
ptyID: string
|
ptyID: string
|
||||||
|
|
@ -2767,6 +2759,14 @@ export type ProviderNotFoundError = {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectCopyError = {
|
||||||
|
name: "ProjectCopyError"
|
||||||
|
data: {
|
||||||
|
message: string
|
||||||
|
forceRequired?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EffectHttpApiErrorForbidden = {
|
export type EffectHttpApiErrorForbidden = {
|
||||||
_tag: "Forbidden"
|
_tag: "Forbidden"
|
||||||
}
|
}
|
||||||
|
|
@ -3699,13 +3699,9 @@ export type ConfigV2ExperimentalPolicy = {
|
||||||
|
|
||||||
export type ProjectDirectories = Array<{
|
export type ProjectDirectories = Array<{
|
||||||
directory: string
|
directory: string
|
||||||
type: "main" | "root" | "git_worktree"
|
strategy?: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export type ProjectCopyCopy = {
|
|
||||||
directory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocationInfo = {
|
export type LocationInfo = {
|
||||||
directory: string
|
directory: string
|
||||||
workspaceID?: string
|
workspaceID?: string
|
||||||
|
|
@ -4226,6 +4222,10 @@ export type ReferenceInfo = {
|
||||||
source: ReferenceLocalSource | ReferenceGitSource
|
source: ReferenceLocalSource | ReferenceGitSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectCopyCopy = {
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
export type EventModelsDevRefreshed = {
|
export type EventModelsDevRefreshed = {
|
||||||
id: string
|
id: string
|
||||||
type: "models-dev.refreshed"
|
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 = {
|
export type EventFileWatcherUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "file.watcher.updated"
|
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 = {
|
export type EventProjectUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "project.updated"
|
type: "project.updated"
|
||||||
|
|
@ -6994,107 +6994,41 @@ export type ProjectDirectoriesResponses = {
|
||||||
|
|
||||||
export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof ProjectDirectoriesResponses]
|
export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof ProjectDirectoriesResponses]
|
||||||
|
|
||||||
export type ExperimentalProjectCopyRemoveData = {
|
export type ExperimentalProjectCopyGenerateNameData = {
|
||||||
body?: {
|
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
|
context?: string
|
||||||
}
|
}
|
||||||
path: {
|
path: {
|
||||||
projectID: string
|
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?: {
|
query?: {
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: 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 =
|
export type ExperimentalProjectCopyGenerateNameError =
|
||||||
ExperimentalProjectCopyRefreshErrors[keyof ExperimentalProjectCopyRefreshErrors]
|
ExperimentalProjectCopyGenerateNameErrors[keyof ExperimentalProjectCopyGenerateNameErrors]
|
||||||
|
|
||||||
export type ExperimentalProjectCopyRefreshResponses = {
|
export type ExperimentalProjectCopyGenerateNameResponses = {
|
||||||
/**
|
/**
|
||||||
* Project copies refreshed
|
* Success
|
||||||
*/
|
*/
|
||||||
204: void
|
200: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExperimentalProjectCopyRefreshResponse =
|
export type ExperimentalProjectCopyGenerateNameResponse =
|
||||||
ExperimentalProjectCopyRefreshResponses[keyof ExperimentalProjectCopyRefreshResponses]
|
ExperimentalProjectCopyGenerateNameResponses[keyof ExperimentalProjectCopyGenerateNameResponses]
|
||||||
|
|
||||||
export type PtyShellsData = {
|
export type PtyShellsData = {
|
||||||
body?: never
|
body?: never
|
||||||
|
|
@ -10951,6 +10885,109 @@ export type V2ReferenceListResponses = {
|
||||||
|
|
||||||
export type V2ReferenceListResponse = V2ReferenceListResponses[keyof 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 = {
|
||||||
|
/**
|
||||||
|
* <No Content>
|
||||||
|
*/
|
||||||
|
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 = {
|
||||||
|
/**
|
||||||
|
* <No Content>
|
||||||
|
*/
|
||||||
|
204: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses]
|
||||||
|
|
||||||
export type PtyConnectData = {
|
export type PtyConnectData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { Authorization } from "./middleware/authorization"
|
||||||
import { LocationGroup } from "./groups/location"
|
import { LocationGroup } from "./groups/location"
|
||||||
import { IntegrationGroup } from "./groups/integration"
|
import { IntegrationGroup } from "./groups/integration"
|
||||||
import { CredentialGroup } from "./groups/credential"
|
import { CredentialGroup } from "./groups/credential"
|
||||||
|
import { ProjectCopyGroup } from "./groups/project-copy"
|
||||||
|
|
||||||
export const Api = HttpApi.make("server")
|
export const Api = HttpApi.make("server")
|
||||||
.add(HealthGroup)
|
.add(HealthGroup)
|
||||||
|
|
@ -35,6 +36,7 @@ export const Api = HttpApi.make("server")
|
||||||
.add(EventGroup)
|
.add(EventGroup)
|
||||||
.add(QuestionGroup)
|
.add(QuestionGroup)
|
||||||
.add(ReferenceGroup)
|
.add(ReferenceGroup)
|
||||||
|
.add(ProjectCopyGroup)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
title: "opencode HttpApi",
|
title: "opencode HttpApi",
|
||||||
|
|
|
||||||
|
|
@ -74,14 +74,23 @@ export const LocationGroup = HttpApiGroup.make("server.location")
|
||||||
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||||
const query = new URL(request.url, "http://localhost").searchParams
|
const query = new URL(request.url, "http://localhost").searchParams
|
||||||
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
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({
|
return Location.Ref.make({
|
||||||
directory: AbsolutePath.make(
|
directory: AbsolutePath.make(directory),
|
||||||
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
|
|
||||||
),
|
|
||||||
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
|
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decode(input: string) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(input)
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
LocationMiddleware,
|
LocationMiddleware,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
57
packages/server/src/groups/project-copy.ts
Normal file
57
packages/server/src/groups/project-copy.ts
Normal file
|
|
@ -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>("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)
|
||||||
|
|
@ -22,6 +22,7 @@ import { LocationHandler } from "./handlers/location"
|
||||||
import { IntegrationHandler } from "./handlers/integration"
|
import { IntegrationHandler } from "./handlers/integration"
|
||||||
import { CredentialHandler } from "./handlers/credential"
|
import { CredentialHandler } from "./handlers/credential"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { ProjectCopyHandler } from "./handlers/project-copy"
|
||||||
|
|
||||||
export const handlers = Layer.mergeAll(
|
export const handlers = Layer.mergeAll(
|
||||||
HealthHandler,
|
HealthHandler,
|
||||||
|
|
@ -40,6 +41,7 @@ export const handlers = Layer.mergeAll(
|
||||||
EventHandler,
|
EventHandler,
|
||||||
QuestionHandler,
|
QuestionHandler,
|
||||||
ReferenceHandler,
|
ReferenceHandler,
|
||||||
|
ProjectCopyHandler,
|
||||||
).pipe(
|
).pipe(
|
||||||
Layer.provide(sessionLocationLayer),
|
Layer.provide(sessionLocationLayer),
|
||||||
Layer.provide(locationLayer),
|
Layer.provide(locationLayer),
|
||||||
|
|
|
||||||
69
packages/server/src/handlers/project-copy.ts
Normal file
69
packages/server/src/handlers/project-copy.ts
Normal file
|
|
@ -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<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -16,14 +16,18 @@ import { useCommandShortcut } from "../keymap"
|
||||||
import { useProject } from "../context/project"
|
import { useProject } from "../context/project"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
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" }
|
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||||
|
type ProjectDirectory = ProjectDirectories[number]
|
||||||
|
|
||||||
export function DialogMoveSession(props: {
|
export function DialogMoveSession(props: {
|
||||||
projectID: string
|
projectID: string
|
||||||
current?: MoveSessionSelection
|
current?: MoveSessionSelection
|
||||||
onSelect: (selection: MoveSessionSelection) => void
|
onSelect: (selection: MoveSessionSelection) => void
|
||||||
initialDirectories?: string[]
|
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||||
|
initialDirectories?: ProjectDirectory[]
|
||||||
initialRemoving?: string
|
initialRemoving?: string
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
@ -32,11 +36,13 @@ export function DialogMoveSession(props: {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const projectContext = useProject()
|
const projectContext = useProject()
|
||||||
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
|
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
|
||||||
const [toDelete, setToDelete] = createSignal<string>()
|
const [toDelete, setToDelete] = createSignal<string>()
|
||||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||||
|
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
||||||
const deleteHint = useCommandShortcut("dialog.move_session.delete")
|
const deleteHint = useCommandShortcut("dialog.move_session.delete")
|
||||||
|
|
||||||
function reopen(initialRemoving?: string) {
|
function reopen(initialRemoving?: string) {
|
||||||
|
|
@ -52,8 +58,8 @@ export function DialogMoveSession(props: {
|
||||||
return result.data?.id === projectID ? result.data.worktree : undefined
|
return result.data?.id === projectID ? result.data.worktree : undefined
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const project = createMemo(() =>
|
const currentCheckout = createMemo(() =>
|
||||||
projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
|
projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [directories, { refetch }] = createResource(
|
const [directories, { refetch }] = createResource(
|
||||||
|
|
@ -61,9 +67,12 @@ export function DialogMoveSession(props: {
|
||||||
async (projectID) => {
|
async (projectID) => {
|
||||||
setWorking(true)
|
setWorking(true)
|
||||||
try {
|
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 })
|
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
|
||||||
return directories.data?.map((item) => item.directory) ?? []
|
return directories.data ?? []
|
||||||
} finally {
|
} finally {
|
||||||
setWorking(false)
|
setWorking(false)
|
||||||
}
|
}
|
||||||
|
|
@ -71,56 +80,88 @@ export function DialogMoveSession(props: {
|
||||||
{ initialValue: props.initialDirectories },
|
{ initialValue: props.initialDirectories },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentDirectory = createMemo(() =>
|
||||||
|
replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()),
|
||||||
|
)
|
||||||
|
const currentRoot = createMemo<ProjectDirectory | undefined>(() => {
|
||||||
|
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<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
||||||
const data = directories()
|
const data = directories()
|
||||||
const main = project()
|
const current = currentRoot()?.directory
|
||||||
if (directories.loading && !data && !main) return [{ title: "Loading project directories...", value: undefined }]
|
if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }]
|
||||||
if (directories.error && !data && !main) return [{ title: "Failed to load project directories", value: undefined }]
|
if (directories.error && !data && !current)
|
||||||
const roots = [...new Set(main ? [main, ...(data ?? [])] : (data ?? []))]
|
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 }]
|
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||||
|
|
||||||
const subdirectories = sync.data.session
|
const subdirectories = sync.data.session
|
||||||
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
||||||
.map((session) => session.directory)
|
.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)
|
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||||
.map((location) => ({
|
.map((location) => ({
|
||||||
location,
|
location,
|
||||||
root: roots
|
root: roots
|
||||||
.filter((root) => {
|
.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)
|
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)
|
.filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined)
|
||||||
const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
|
|
||||||
|
const list = [...roots.map((root) => ({ location: root.directory, root })), ...subdirectories].toSorted((a, b) => {
|
||||||
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
|
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
|
||||||
if (root !== 0) return root
|
if (root !== 0) return root
|
||||||
if (a.location === a.root) return -1
|
if (a.location === a.root.directory) return -1
|
||||||
if (b.location === b.root) return 1
|
if (b.location === b.root.directory) return 1
|
||||||
return a.location.localeCompare(b.location)
|
return a.location.localeCompare(b.location)
|
||||||
})
|
})
|
||||||
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
||||||
|
|
||||||
return list.map((item) => {
|
return list.map((item) => {
|
||||||
const title = abbreviateHome(item.location, paths.home)
|
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 visible = Locale.truncateLeft(title, titleWidth)
|
||||||
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
|
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
|
||||||
const deleting = toDelete() === item.location
|
const deleting = toDelete() === item.location
|
||||||
const isRemoving = removing() === item.location
|
const isRemoving = removing() === item.location
|
||||||
return {
|
return {
|
||||||
title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
|
title,
|
||||||
titleView: isRemoving ? (
|
titleView: isRemoving ? (
|
||||||
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
|
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
|
||||||
) : !deleting && suffix ? (
|
) : deleting ? (
|
||||||
|
<span style={{ fg: theme.text }}>Press {deleteHint()} again to confirm</span>
|
||||||
|
) : suffix ? (
|
||||||
<>
|
<>
|
||||||
{visible.slice(0, split)}
|
{visible.slice(0, split)}
|
||||||
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
||||||
</>
|
</>
|
||||||
) : undefined,
|
) : undefined,
|
||||||
bg: deleting ? theme.error : undefined,
|
bg: deleting ? theme.error : undefined,
|
||||||
value: { type: "directory", directory: item.location, subdirectory: item.location !== item.root } as const,
|
value: {
|
||||||
category: item.root === main ? "Project" : "Working copies",
|
type: "directory",
|
||||||
|
directory: item.location,
|
||||||
|
subdirectory: item.location !== item.root.directory,
|
||||||
|
} as const,
|
||||||
|
category: item.root.directory === current ? "Current" : "Other",
|
||||||
titleWidth,
|
titleWidth,
|
||||||
truncateTitle: "left" as const,
|
truncateTitle: "left" as const,
|
||||||
}
|
}
|
||||||
|
|
@ -128,32 +169,56 @@ export function DialogMoveSession(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => {
|
const current = createMemo(() => {
|
||||||
if (directories.loading || loadedProject.loading || !props.current) return
|
if (directories.loading || loadedProject.loading) return
|
||||||
if (props.current.type === "new") return props.current
|
const replacement = replacementCurrent()
|
||||||
const directory = props.current.directory
|
if (replacement) return { type: "directory", directory: replacement, subdirectory: false } as const
|
||||||
return options().find((option) => option.value?.type === "directory" && option.value.directory === directory)?.value
|
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<MoveSessionSelection | undefined>) {
|
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
|
||||||
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
|
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
|
||||||
const data = directories()
|
const data = directories()
|
||||||
const main = project()
|
const selected = option.value
|
||||||
if (!data || !main || option.value.directory === main || !data.includes(option.value.directory)) return
|
const root = data?.find((item) => item.directory === selected.directory)
|
||||||
if (toDelete() !== option.value.directory) {
|
if (!root?.strategy) return
|
||||||
setToDelete(option.value.directory)
|
const deletingCurrent = selected.directory === currentRoot()?.directory
|
||||||
|
if (toDelete() !== selected.directory) {
|
||||||
|
setToDelete(selected.directory)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setToDelete(undefined)
|
setToDelete(undefined)
|
||||||
setRemoving(option.value.directory)
|
setRemoving(selected.directory)
|
||||||
setWorking(true)
|
setWorking(true)
|
||||||
const result = await sdk.client.experimental.projectCopy
|
const result = await sdk.client.v2.projectCopy
|
||||||
.remove({ projectID: props.projectID, directory: option.value.directory, force: false })
|
.remove({
|
||||||
|
projectID: props.projectID,
|
||||||
|
location: { directory: sdk.directory },
|
||||||
|
directory: selected.directory,
|
||||||
|
force: false,
|
||||||
|
})
|
||||||
.catch((error) => ({ error }))
|
.catch((error) => ({ error }))
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setRemoving(undefined)
|
setRemoving(undefined)
|
||||||
setWorking(false)
|
setWorking(false)
|
||||||
if ("data" in result.error && result.error.data.forceRequired) {
|
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 ?? [], {
|
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
|
||||||
title: "Delete working copy?",
|
title: "Delete working copy?",
|
||||||
message: "This working copy has file changes. Do you want to delete it anyway?",
|
message: "This working copy has file changes. Do you want to delete it anyway?",
|
||||||
|
|
@ -162,9 +227,14 @@ export function DialogMoveSession(props: {
|
||||||
reopen()
|
reopen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reopen(option.value.directory)
|
reopen(selected.directory)
|
||||||
const forced = await sdk.client.experimental.projectCopy
|
const forced = await sdk.client.v2.projectCopy
|
||||||
.remove({ projectID: props.projectID, directory: option.value.directory, force: true })
|
.remove({
|
||||||
|
projectID: props.projectID,
|
||||||
|
location: { directory: sdk.directory },
|
||||||
|
directory: selected.directory,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
.catch((error) => ({ error }))
|
.catch((error) => ({ error }))
|
||||||
if (forced.error) {
|
if (forced.error) {
|
||||||
toast.show({
|
toast.show({
|
||||||
|
|
@ -172,7 +242,12 @@ export function DialogMoveSession(props: {
|
||||||
title: "Failed to delete project copy",
|
title: "Failed to delete project copy",
|
||||||
message: errorMessage(forced.error),
|
message: errorMessage(forced.error),
|
||||||
})
|
})
|
||||||
|
reopen()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setRemoving(undefined)
|
||||||
|
setWorking(false)
|
||||||
|
if (await removedCurrent(deletingCurrent)) return
|
||||||
reopen()
|
reopen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +260,8 @@ export function DialogMoveSession(props: {
|
||||||
}
|
}
|
||||||
await refetch()
|
await refetch()
|
||||||
setRemoving(undefined)
|
setRemoving(undefined)
|
||||||
|
setWorking(false)
|
||||||
|
if (await removedCurrent(deletingCurrent)) return
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => dialog.setSize("xlarge"))
|
onMount(() => dialog.setSize("xlarge"))
|
||||||
|
|
@ -219,11 +296,11 @@ export function DialogMoveSession(props: {
|
||||||
{
|
{
|
||||||
command: "dialog.move_session.delete",
|
command: "dialog.move_session.delete",
|
||||||
title: "delete",
|
title: "delete",
|
||||||
disabled: (option) =>
|
disabled: (option) => {
|
||||||
!option?.value ||
|
const value = option?.value
|
||||||
option.value.type !== "directory" ||
|
if (!value || value.type !== "directory" || value.subdirectory) return true
|
||||||
option.value.subdirectory ||
|
return !directories()?.find((item) => item.directory === value.directory)?.strategy
|
||||||
option.value.directory === project(),
|
},
|
||||||
onTrigger: remove,
|
onTrigger: remove,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -236,3 +313,9 @@ export function DialogMoveSession(props: {
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useToast } from "../../ui/toast"
|
||||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
||||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||||
|
import { useProject } from "../../context/project"
|
||||||
|
|
||||||
function moveReminderText(directory: string) {
|
function moveReminderText(directory: string) {
|
||||||
return `<system-reminder>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.</system-reminder>`
|
return `<system-reminder>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.</system-reminder>`
|
||||||
|
|
@ -20,6 +21,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const homeDestination = useHomeSessionDestination()
|
const homeDestination = useHomeSessionDestination()
|
||||||
|
const project = useProject()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const [creating, setCreating] = createSignal(false)
|
const [creating, setCreating] = createSignal(false)
|
||||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||||
|
|
@ -31,12 +33,17 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
setProgress("Creating copy")
|
setProgress("Creating copy")
|
||||||
try {
|
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,
|
projectID,
|
||||||
|
location: { directory: sdk.directory },
|
||||||
strategy: "git_worktree",
|
strategy: "git_worktree",
|
||||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||||
context,
|
name: generated.data.name,
|
||||||
},
|
},
|
||||||
{ throwOnError: true },
|
{ throwOnError: true },
|
||||||
)
|
)
|
||||||
|
|
@ -74,8 +81,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||||
directory: session.directory,
|
directory: session.directory,
|
||||||
subdirectory: !!session.path,
|
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) => {
|
onSelect={(selection) => {
|
||||||
const sessionID = input.sessionID()
|
const sessionID = input.sessionID()
|
||||||
if (!sessionID) {
|
if (!sessionID) {
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||||
const directories = project.data?.id
|
const directories = project.data?.id
|
||||||
? await sdk.client.project.directories({ projectID: project.data.id, workspace })
|
? await sdk.client.project.directories({ projectID: project.data.id, workspace })
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("instance", "path", reconcile(instancePath.data || defaultPath))
|
setStore("instance", "path", reconcile(instancePath.data || defaultPath))
|
||||||
setStore("project", "id", project.data?.id)
|
setStore("project", "id", project.data?.id)
|
||||||
setStore("project", "worktree", project.data?.worktree)
|
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue