diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts new file mode 100644 index 000000000000..9825cbf93390 --- /dev/null +++ b/packages/core/src/control-plane/move-session.ts @@ -0,0 +1,128 @@ +export * as MoveSession from "./move-session" + +import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { EventV2 } from "../event" +import { Git } from "../git" +import { Location } from "../location" +import { ProjectV2 } from "../project" +import { SessionV2 } from "../session" +import { SessionEvent } from "../session/event" +import { SessionSchema } from "../session/schema" +import { AbsolutePath, RelativePath } from "../schema" +import path from "path" + +export const Destination = Schema.Struct({ + directory: AbsolutePath, +}).annotate({ identifier: "MoveSession.Destination" }) +export type Destination = typeof Destination.Type + +export const Input = Schema.Struct({ + sessionID: SessionSchema.ID, + destination: Destination, + moveChanges: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "MoveSession.Input" }) +export type Input = typeof Input.Type + +export class DestinationProjectMismatchError extends Schema.TaggedErrorClass()( + "MoveSession.DestinationProjectMismatchError", + { + expected: ProjectV2.ID, + actual: ProjectV2.ID, + }, +) {} + +export class ApplyChangesError extends Schema.TaggedErrorClass()("MoveSession.ApplyChangesError", { + message: Schema.String, +}) {} + +export class CaptureChangesError extends Schema.TaggedErrorClass()( + "MoveSession.CaptureChangesError", + { + message: Schema.String, + }, +) {} + +export class ResetSourceChangesError extends Schema.TaggedErrorClass()( + "MoveSession.ResetSourceChangesError", + { + directory: AbsolutePath, + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export type Error = + | SessionV2.NotFoundError + | DestinationProjectMismatchError + | CaptureChangesError + | ApplyChangesError + | ResetSourceChangesError + +export interface Interface { + readonly moveSession: (input: Input) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ControlPlaneMoveSession") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const git = yield* Git.Service + const events = yield* EventV2.Service + const project = yield* ProjectV2.Service + const session = yield* SessionV2.Service + + const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) { + const current = yield* session.get(input.sessionID) + const directory = AbsolutePath.make(input.destination.directory) + if (current.location.directory === directory) return + + const source = yield* project.resolve(current.location.directory) + const destination = yield* project.resolve(directory) + if (current.projectID !== destination.id) { + return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) + } + + const patch = + input.moveChanges && source.directory !== destination.directory + ? yield* git + .patch(current.location.directory) + .pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message }))) + : "" + if (patch) { + yield* git + .applyPatch({ directory, patch }) + .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) + } + + yield* events.publish(SessionEvent.Moved, { + sessionID: input.sessionID, + location: Location.Ref.make({ directory }), + subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), + timestamp: yield* DateTime.now, + }) + + if (patch) { + yield* git.softResetChanges(current.location.directory).pipe( + Effect.mapError( + (error) => + new ResetSourceChangesError({ + directory: current.location.directory, + message: error.message, + cause: error.cause, + }), + ), + ) + } + }) + + return Service.of({ moveSession }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(EventV2.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), + Layer.provide(SessionV2.defaultLayer), +) diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 8b50f6772850..806decbc6743 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -1,7 +1,7 @@ export * as Git from "./git" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" @@ -33,6 +33,13 @@ export class WorktreeError extends Schema.TaggedErrorClass()("Git cause: Schema.optional(Schema.Defect), }) {} +export class PatchError extends Schema.TaggedErrorClass()("Git.PatchError", { + operation: Schema.Literals(["capture", "apply", "reset"]), + directory: AbsolutePath, + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + export interface Interface { readonly find: (input: AbsolutePath) => Effect.Effect readonly remote: (repo: Repo, name?: string) => Effect.Effect @@ -52,6 +59,10 @@ export interface Interface { readonly fetchBranch: (directory: string, branch: string) => Effect.Effect readonly checkout: (directory: string, branch: string) => Effect.Effect readonly reset: (directory: string, target: string) => Effect.Effect + readonly patch: (directory: AbsolutePath) => Effect.Effect + readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect + readonly resetChanges: (directory: AbsolutePath) => Effect.Effect + readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect readonly worktreeList: (repo: Repo) => Effect.Effect @@ -159,6 +170,156 @@ export const layer = Layer.effect( execute(directory, proc)(["reset", "--hard", target]), ) + const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) { + const root = yield* execute( + directory, + proc, + )(["rev-parse", "--show-toplevel"]).pipe( + Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + ) + if (root.exitCode !== 0) { + return yield* new PatchError({ + operation: "capture", + directory, + message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root", + }) + } + const repo = AbsolutePath.make(resolvePath(directory, root.text)) + const scope = path.relative(repo, directory).replaceAll("\\", "/") || "." + const tracked = yield* execute( + repo, + proc, + )(["diff", "--binary", "HEAD", "--", scope]).pipe( + Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + ) + if (tracked.exitCode !== 0) { + return yield* new PatchError({ + operation: "capture", + directory, + message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes", + }) + } + + const untracked = yield* execute( + repo, + proc, + )(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe( + Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + ) + if (untracked.exitCode !== 0) { + return yield* new PatchError({ + operation: "capture", + directory, + message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes", + }) + } + + const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) => + execute( + repo, + proc, + )(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe( + Effect.mapError( + (cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }), + ), + Effect.flatMap((result) => + // git diff --no-index returns 1 when differences were found. + result.exitCode === 0 || result.exitCode === 1 + ? Effect.succeed(result.text) + : Effect.fail( + new PatchError({ + operation: "capture", + directory, + message: + result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`, + }), + ), + ), + ), + ) + return [tracked.text, ...created].filter(Boolean).join("\n") + }) + + const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) { + const result = yield* proc + .run( + ChildProcess.make("git", ["apply", "-"], { + cwd: input.directory, + extendEnv: true, + stdin: Stream.make(new TextEncoder().encode(input.patch)), + }), + ) + .pipe( + Effect.mapError( + (cause) => + new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }), + ), + ) + if (result.exitCode === 0) return + return yield* new PatchError({ + operation: "apply", + directory: input.directory, + message: + result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes", + }) + }) + + const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) { + const reset = yield* execute( + directory, + proc, + )(["reset", "--hard", "HEAD"]).pipe( + Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + ) + if (reset.exitCode !== 0) { + return yield* new PatchError({ + operation: "reset", + directory, + message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes", + }) + } + const clean = yield* execute( + directory, + proc, + )(["clean", "-fd"]).pipe( + Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + ) + if (clean.exitCode === 0) return + return yield* new PatchError({ + operation: "reset", + directory, + message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes", + }) + }) + + const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) { + const checkout = yield* execute( + directory, + proc, + )(["checkout", "--", "."]).pipe( + Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + ) + if (checkout.exitCode !== 0) { + return yield* new PatchError({ + operation: "reset", + directory, + message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes", + }) + } + const clean = yield* execute( + directory, + proc, + )(["clean", "-fd", "--", "."]).pipe( + Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + ) + if (clean.exitCode === 0) return + return yield* new PatchError({ + operation: "reset", + directory, + message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes", + }) + }) + const worktree = Effect.fnUntraced(function* ( operation: "create" | "remove" | "list", repo: Repo, @@ -216,6 +377,10 @@ export const layer = Layer.effect( fetchBranch, checkout, reset, + patch, + applyPatch, + resetChanges, + softResetChanges, worktreeCreate, worktreeRemove, worktreeList, diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts index 7a27b2e2b84d..a83bcd4cc215 100644 --- a/packages/core/src/project/copy-strategies.ts +++ b/packages/core/src/project/copy-strategies.ts @@ -25,11 +25,17 @@ export function makeStrategies(input: { yield* input.git.worktreeRemove({ repo: found, directory }) }), list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) { - const entries = yield* input.git.worktreeList(repo(directory)) + const found = yield* input.git.find(directory) + if (!found) return yield* new DirectoryUnavailableError({ directory }) + const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store + const entries = yield* input.git.worktreeList(found) return yield* Effect.forEach(entries, (entry) => - entry === directory + entry === core ? Effect.succeed(undefined) - : input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))), + : input.canonical(entry).pipe( + Effect.map((directory) => ({ directory })), + Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)), + ), ).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined))) }), detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) { diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 317b7403b0dc..07b8eb4000a1 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -2,6 +2,7 @@ export * as ProjectCopy from "./copy" import { and, eq, inArray } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" +import path from "path" import { AbsolutePath } from "../schema" import { FSUtil } from "../fs-util" import { Git } from "../git" @@ -10,6 +11,7 @@ import { EventV2 } from "../event" import { Project } from "../project" import { ProjectDirectoryTable } from "./sql" import { makeStrategies } from "./copy-strategies" +import { Slug } from "../util/slug" export const StrategyID = Schema.Literal("git_worktree") export type StrategyID = typeof StrategyID.Type @@ -24,6 +26,8 @@ export const CreateInput = Schema.Struct({ strategy: StrategyID, sourceDirectory: AbsolutePath, directory: AbsolutePath, + name: Schema.optional(Schema.String), + context: Schema.optional(Schema.String), }).annotate({ identifier: "ProjectCopy.CreateInput" }) export type CreateInput = typeof CreateInput.Type @@ -183,10 +187,18 @@ export const layer = Layer.effect( }) const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) { - if (yield* fs.existsSafe(input.directory)) - return yield* new DestinationExistsError({ directory: input.directory }) + yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie) + const name = input.name ?? Slug.create() + let suffix = 1 + let copyDirectory = AbsolutePath.make(path.join(input.directory, name)) + while (yield* fs.existsSafe(copyDirectory)) { + suffix++ + if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory }) + copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`)) + } + const result = yield* strategy(input.strategy).create({ - directory: input.directory, + directory: copyDirectory, sourceDirectory: yield* source(input.sourceDirectory, input.projectID), }) yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy)) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 38496ea0740e..54203f1044b7 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -77,11 +77,6 @@ type CreateInput = { location: Location.Ref } -type MoveInput = { - sessionID: SessionSchema.ID - location: Location.Ref -} - type CompactInput = { sessionID: SessionSchema.ID prompt?: Prompt @@ -110,7 +105,6 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr export interface Interface { readonly list: (input?: ListInput) => Effect.Effect readonly create: (input: CreateInput) => Effect.Effect - readonly move: (input: MoveInput) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { sessionID: SessionSchema.ID @@ -417,9 +411,6 @@ export const layer = Layer.effect( yield* result.get(sessionID) yield* execution.resume(sessionID) }), - move: Effect.fn("V2Session.move")(function* () { - return yield* new OperationUnavailableError({ operation: "move" }) - }), }) return result diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 0e82c0bac0c8..acfbb3fb97f7 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -7,6 +7,8 @@ import { ToolOutput } from "../tool-output" import { V2Schema } from "../v2-schema" import { FileAttachment, Prompt } from "./prompt" import { SessionSchema } from "./schema" +import { Location } from "../location" +import { RelativePath } from "../schema" export { FileAttachment } @@ -65,6 +67,17 @@ export const ModelSwitched = EventV2.define({ }) export type ModelSwitched = typeof ModelSwitched.Type +export const Moved = EventV2.define({ + type: "session.next.moved", + ...options, + schema: { + ...Base, + location: Location.Ref, + subdirectory: RelativePath.pipe(Schema.optional), + }, +}) +export type Moved = typeof Moved.Type + export const Prompted = EventV2.define({ type: "session.next.prompted", ...options, @@ -387,6 +400,7 @@ export namespace Compaction { const DurableDefinitions = [ AgentSwitched, ModelSwitched, + Moved, Prompted, Synthetic, Shell.Started, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 0f8e8088f92e..65d53e151784 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -142,6 +142,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, + "session.next.moved": () => Effect.void, "session.next.prompted": (event) => { return adapter.appendMessage( new SessionMessage.User({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index cf201d5c0977..f7bb63e979c7 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -10,6 +10,7 @@ import { WorkspaceTable } from "../control-plane/workspace.sql" import { SessionMessage } from "./message" import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" +import { WorkspaceV2 } from "../workspace" import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" import type { DeepMutable } from "../schema" @@ -245,6 +246,19 @@ export const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie), ) + yield* events.project(SessionEvent.Moved, (event) => + db + .update(SessionTable) + .set({ + directory: event.data.location.directory, + path: event.data.subdirectory, + workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, + time_updated: DateTime.toEpochMillis(event.data.timestamp), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie), + ) yield* events.project(SessionV1.Event.Deleted, (event) => db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), ) diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts new file mode 100644 index 000000000000..c00601938753 --- /dev/null +++ b/packages/core/test/move-session.test.ts @@ -0,0 +1,247 @@ +import { describe, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { eq } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" +import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Git } from "@opencode-ai/core/git" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const database = Database.layerFromPath(":memory:") +const events = EventV2.layer.pipe(Layer.provide(database)) +const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events)) +const project = Project.layer.pipe( + Layer.provide(database), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Git.defaultLayer), +) +const store = SessionStore.layer.pipe(Layer.provide(database)) +const sessions = SessionV2.layer.pipe( + Layer.provide(database), + Layer.provide(events), + Layer.provide(project), + Layer.provide(store), + Layer.provide(SessionExecution.noopLayer), +) +const layer = MoveSession.layer.pipe( + Layer.provide(database), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(events), + Layer.provide(project), + Layer.provide(sessions), +) +const it = testEffect(Layer.mergeAll(layer, database, events, project, projector, store, SessionExecution.noopLayer, sessions)) + +function abs(input: string) { + return AbsolutePath.make(input) +} + +async function initRepo(directory: string) { + await $`git init`.cwd(directory).quiet() + await $`git config core.autocrlf false`.cwd(directory).quiet() + await $`git config core.fsmonitor false`.cwd(directory).quiet() + await $`git config commit.gpgsign false`.cwd(directory).quiet() + await $`git config user.email test@opencode.test`.cwd(directory).quiet() + await $`git config user.name Test`.cwd(directory).quiet() + await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n") + await $`git add tracked.txt`.cwd(directory).quiet() + await $`git commit -m root`.cwd(directory).quiet() +} + +describe("MoveSession", () => { + it.live("moves session changes to another project directory", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const destination = abs(`${root.path}-move-destination`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet()) + const moved = abs(yield* Effect.promise(() => fs.realpath(destination))) + yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) + yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) + + const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id + const sessionID = SessionV2.ID.make("ses_move") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "move", + directory: source, + title: "move", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + yield* MoveSession.Service.use((service) => + service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }), + ) + + expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n") + expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false) + expect( + yield* db + .select({ directory: SessionTable.directory, path: SessionTable.path }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get(), + ).toEqual({ directory: moved, path: "" }) + }), + ) + + it.live("moves within a checkout without transferring existing changes", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const destination = abs(path.join(source, "packages")) + yield* Effect.promise(() => fs.mkdir(destination)) + yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) + yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) + + const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id + const sessionID = SessionV2.ID.make("ses_move_nested") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "move-nested", + directory: source, + title: "move nested", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + yield* MoveSession.Service.use((service) => + service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }), + ) + + expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n") + expect( + yield* db + .select({ directory: SessionTable.directory, path: SessionTable.path }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get(), + ).toEqual({ directory: destination, path: "packages" }) + }), + ) + + it.live("moves nested session changes without cleaning unrelated files", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const sourceDirectory = abs(path.join(source, "packages")) + yield* Effect.promise(() => fs.mkdir(sourceDirectory)) + yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n")) + yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n")) + yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet()) + yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet()) + const destination = abs(`${root.path}-move-nested-destination`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet()) + const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages")) + yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n")) + yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n")) + yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet()) + yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n")) + yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n")) + yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n")) + + const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id + const sessionID = SessionV2.ID.make("ses_move_nested_checkout") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "move-nested-checkout", + directory: sourceDirectory, + title: "move nested checkout", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + yield* MoveSession.Service.use((service) => + service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }), + ) + + expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe( + "initial\n", + ) + expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false) + expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe( + "staged\n", + ) + expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe( + "M packages/staged.txt\n", + ) + expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n") + }), + ) +}) diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 94c4a8adfcd0..7da03cbe5d6d 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -97,9 +97,11 @@ describe("ProjectCopy", () => { const input = yield* setup() const copy = yield* ProjectCopy.Service const events = yield* EventV2.Service - const target = abs(`${input.root.path}-copy-created`) + const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path))) + const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created")) + const target = abs(path.join(parent, "copy")) yield* Effect.addFinalizer(() => - Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore), + Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore), ) const fiber = yield* events .subscribe(ProjectCopy.Event.Updated) @@ -110,8 +112,10 @@ describe("ProjectCopy", () => { projectID: input.projectID, strategy: "git_worktree", sourceDirectory: input.sourceDirectory, - directory: target, + directory: parent, + name: "copy", }) + expect(created.directory).toBe(target) expect(yield* stored(input.projectID)).toEqual( [ { directory: input.sourceDirectory, type: "main" as const }, @@ -127,6 +131,71 @@ describe("ProjectCopy", () => { }), ) + it.live("adds a numeric suffix when a copy directory already exists", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path))) + const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix")) + const target = abs(path.join(parent, "copy-3")) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2"))) + + const created = yield* copy.create({ + projectID: input.projectID, + strategy: "git_worktree", + sourceDirectory: input.sourceDirectory, + directory: parent, + name: "copy", + }) + + expect(created.directory).toBe(target) + expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe( + true, + ) + expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe( + true, + ) + + yield* copy.remove({ projectID: input.projectID, directory: created.directory }) + }), + ) + + it.live("fails after ten copy directory conflicts", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path))) + const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts")) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(() => + Promise.all( + Array.from({ length: 10 }, (_, index) => + fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }), + ), + ), + ) + + const error = yield* copy + .create({ + projectID: input.projectID, + strategy: "git_worktree", + sourceDirectory: input.sourceDirectory, + directory: parent, + name: "copy", + }) + .pipe(Effect.flip) + + expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError) + expect(error.directory).toBe(abs(path.join(parent, "copy-10"))) + }), + ) + it.live("does not publish an event when refresh finds no directory changes", () => Effect.gen(function* () { const input = yield* setup() @@ -181,6 +250,31 @@ describe("ProjectCopy", () => { }), ) + it.live("refresh ignores stale git worktree registrations", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const stale = abs(`${input.root.path}-copy-stale`) + const target = abs(`${input.root.path}-copy-after-stale`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet()) + yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true })) + yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet()) + + yield* copy.refresh({ projectID: input.projectID }) + + const discovered = abs(yield* Effect.promise(() => fs.realpath(target))) + expect(yield* stored(input.projectID)).toEqual( + [ + { directory: input.sourceDirectory, type: "main" as const }, + { directory: discovered, type: "git_worktree" as const }, + ].toSorted((a, b) => a.directory.localeCompare(b.directory)), + ) + }), + ) + it.live("refresh with no roots is a no-op", () => Effect.gen(function* () { const copy = yield* ProjectCopy.Service diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 7d373cd75aeb..7c83ec5696a3 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -242,7 +242,6 @@ describe("SessionV2.create", () => { Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")), ) - expect(yield* unavailable(session.move({ sessionID: created.id, location }))).toBe("move") expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent") diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx new file mode 100644 index 000000000000..bb808f8a5b96 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx @@ -0,0 +1,129 @@ +import { useTerminalDimensions } from "@opentui/solid" +import { createMemo, createResource, createSignal, onMount, Show } from "solid-js" +import path from "path" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" +import { useTheme } from "@tui/context/theme" +import { useKV } from "@tui/context/kv" +import { useSync } from "@tui/context/sync" +import { Global } from "@opencode-ai/core/global" +import { Locale } from "@/util/locale" +import "opentui-spinner/solid" + +const REFRESH_FRAMES = ["■", "⬝"] + +export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" } + +export function DialogMoveSession(props: { projectID: string; onSelect: (selection: MoveSessionSelection) => void }) { + const dialog = useDialog() + const sdk = useSDK() + const dimensions = useTerminalDimensions() + const { theme } = useTheme() + const kv = useKV() + const sync = useSync() + const [refreshing, setRefreshing] = createSignal(false) + + const [directories] = createResource( + () => props.projectID, + async (projectID) => { + setRefreshing(true) + const [, project] = await Promise.all([ + sdk.client.experimental.projectCopy + .refresh({ projectID }, { throwOnError: true }) + .finally(() => setRefreshing(false)), + sdk.client.project.current({}, { throwOnError: true }), + ]) + const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) + return { + directories: directories.data ?? [], + main: project.data?.id === projectID ? project.data.worktree : undefined, + } + }, + ) + + const options = createMemo[]>(() => { + if (directories.loading) return [{ title: "Loading project directories...", value: undefined }] + if (directories.error) return [{ title: "Failed to load project directories", value: undefined }] + const data = directories() + const roots = data ? [...new Set(data.main ? [data.main, ...data.directories] : data.directories)] : [] + if (roots.length === 0) return [{ title: "No project directories found", value: undefined }] + const subdirectories = sync.data.session + .filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path)) + .map((session) => session.directory) + .filter((directory) => !roots.includes(directory)) + .filter((directory, index, directories) => directories.indexOf(directory) === index) + .map((location) => ({ + location, + root: roots + .filter((root) => { + const relative = path.relative(root, location) + return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative) + }) + .toSorted((a, b) => b.length - a.length)[0], + })) + .filter((item): item is { location: string; root: string } => item.root !== undefined) + const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => { + const root = roots.indexOf(a.root) - roots.indexOf(b.root) + if (root !== 0) return root + if (a.location === a.root) return -1 + if (b.location === b.root) return 1 + return a.location.localeCompare(b.location) + }) + const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12) + return list.map((item) => { + const title = + Global.Path.home && + (item.location === Global.Path.home || item.location.startsWith(Global.Path.home + path.sep)) + ? item.location.replace(Global.Path.home, "~") + : item.location + const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location) + const visible = Locale.truncateLeft(title, titleWidth) + const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length + return { + title, + titleView: suffix ? ( + <> + {visible.slice(0, split)} + {visible.slice(split)} + + ) : undefined, + value: item.location, + category: item.root === data?.main ? "Project" : "Working copies", + titleWidth, + truncateTitle: "left" as const, + } + }) + }) + + onMount(() => dialog.setSize("xlarge")) + + return ( + + { + if (option.value) props.onSelect({ type: "directory", directory: option.value }) + }} + actions={[ + { + command: "dialog.move_session.new", + title: "new", + onTrigger: () => props.onSelect({ type: "new" }), + }, + ]} + footer={ + + + ⬝}> + + + refreshing + + + } + /> + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx index b2cb20630c38..10ad9fc96d14 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx @@ -103,7 +103,7 @@ export function DialogWorkspaceFileChanges(props: { - Do you want to apply these changes after warping? + Do you want to move these changes with the session? diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f287ea25c5c3..dc363a4b9bc0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -51,18 +51,13 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" -import { - confirmWorkspaceFileChanges, - openWorkspaceSelect, - warpWorkspaceSession, - type WorkspaceSelection, -} from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" -import { type WorkspaceStatus } from "../workspace-label" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../context/tui-config" +import { usePromptWorkspace } from "./workspace" +import { usePromptMove } from "./move" export type PromptProps = { sessionID?: string @@ -195,109 +190,12 @@ export function Prompt(props: PromptProps) { }) const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() - const [workspaceSelection, setWorkspaceSelection] = createSignal() - const [workspaceCreating, setWorkspaceCreating] = createSignal(false) - const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) - const [warpNotice, setWarpNotice] = createSignal() + const workspace = usePromptWorkspace(props.sessionID) + const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) - function selectWorkspace(selection: WorkspaceSelection | undefined) { - setWorkspaceSelection(selection) - } - - function setCreatingWorkspace(creating: boolean) { - setWorkspaceCreating(creating) - } - - function showWarpNotice(name: string) { - setWarpNotice(`Warped to ${name}`) - setTimeout(() => setWarpNotice(undefined), 4000) - } - - async function createWorkspace(selection: Extract) { - setCreatingWorkspace(true) - let result - try { - result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) - } catch (err) { - selectWorkspace(undefined) - setCreatingWorkspace(false) - toast.show({ - title: "Creating workspace failed", - message: errorMessage(err), - variant: "error", - }) - return - } - if (result.error || !result.data) { - selectWorkspace(undefined) - setCreatingWorkspace(false) - toast.show({ - title: "Creating workspace failed", - message: errorMessage(result.error ?? "no response"), - variant: "error", - }) - return - } - - await project.workspace.sync() - const workspace = result.data - selectWorkspace({ - type: "existing", - workspaceID: workspace.id, - workspaceType: workspace.type, - workspaceName: workspace.name, - }) - setCreatingWorkspace(false) - return workspace - } - - async function warpSession(selection: WorkspaceSelection) { - if (!props.sessionID) { - selectWorkspace(selection) - dialog.clear() - if (selection.type === "new") void createWorkspace(selection) - return - } - const sourceWorkspaceID = project.workspace.current() - const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) - if (copyChanges === undefined) return - selectWorkspace(selection) - dialog.clear() - - const workspace = - selection.type === "none" - ? { id: null, name: "local project" } - : selection.type === "existing" - ? { id: selection.workspaceID, name: selection.workspaceName } - : await createWorkspace(selection) - if (!workspace) return - - const warped = await warpWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - sourceWorkspaceID, - workspaceID: workspace.id, - sessionID: props.sessionID, - copyChanges, - }) - if (warped) showWarpNotice(workspace.name) - } - - createEffect(() => { - if (!workspaceCreating()) { - setWorkspaceCreatingDots(3) - return - } - const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000) - onCleanup(() => clearInterval(timer)) - }) - function promptModelWarning() { toast.show({ variant: "warning", @@ -623,16 +521,17 @@ export function Prompt(props: PromptProps) { enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, slashName: "warp", run: () => { - void openWorkspaceSelect({ - dialog, - sdk, - sync, - project, - toast, - onSelect: (selection) => { - void warpSession(selection) - }, - }) + workspace.open() + }, + }, + { + title: "Move session", + desc: "Move the session to another project directory", + name: "session.move", + category: "Session", + slashName: "move", + run: () => { + move.open() }, }, ].map((entry) => ({ @@ -656,6 +555,7 @@ export function Prompt(props: PromptProps) { "prompt.stash.list", "session.interrupt", "workspace.set", + "session.move", ]), })) @@ -1025,7 +925,7 @@ export function Prompt(props: PromptProps) { } async function submitInner() { - setWarpNotice(undefined) + workspace.clearNotice() // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read @@ -1035,7 +935,7 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() } if (props.disabled) return false - if (workspaceCreating()) return false + if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() @@ -1058,16 +958,7 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - void openWorkspaceSelect({ - dialog, - sdk, - sync, - project, - toast, - onSelect: (selection) => { - void warpSession(selection) - }, - }) + workspace.open() return false }} /> @@ -1077,16 +968,22 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() let sessionID = props.sessionID + let finishMoveProgress = false if (sessionID == null) { - const workspace = workspaceSelection() + const selectedWorkspace = workspace.selection() const workspaceID = iife(() => { - if (!workspace) return undefined - if (workspace.type === "none") return undefined - if (workspace.type === "existing") return workspace.workspaceID + if (!selectedWorkspace) return undefined + if (selectedWorkspace.type === "none") return undefined + if (selectedWorkspace.type === "existing") return selectedWorkspace.workspaceID return undefined }) + const directory = await move.getDirectory(store.prompt.input) + if (move.pending() && !directory) return false + finishMoveProgress = Boolean(move.progress()) + const res = await sdk.client.session.create({ + directory, workspace: workspaceID, agent: agent.name, model: { @@ -1097,6 +994,7 @@ export function Prompt(props: PromptProps) { }) if (res.error) { + if (finishMoveProgress) move.finishSubmit() console.log("Creating a session failed:", res.error) toast.show({ @@ -1146,6 +1044,7 @@ export function Prompt(props: PromptProps) { : [] if (store.mode === "shell") { + move.startSubmit() void sdk.client.session.shell({ sessionID, agent: agent.name, @@ -1164,6 +1063,7 @@ export function Prompt(props: PromptProps) { return sync.data.command.some((x) => x.name === command) }) ) { + move.startSubmit() // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) @@ -1187,6 +1087,7 @@ export function Prompt(props: PromptProps) { })), }) } else { + move.startSubmit() sdk.client.session .prompt({ sessionID, @@ -1231,6 +1132,7 @@ export function Prompt(props: PromptProps) { }, 50) } input.clear() + if (finishMoveProgress) move.finishSubmit() return true } const exit = useExit() @@ -1427,29 +1329,6 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` }) - const workspaceLabel = createMemo< - | { type: "new"; workspaceType: string } - | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } - | undefined - >(() => { - const selected = workspaceSelection() - if (!selected) return - if (selected.type === "none") return - if (props.sessionID && !workspaceCreating()) return - if (selected.type === "new") { - return { - type: "new", - workspaceType: selected.workspaceType, - } - } - return { - type: "existing", - workspaceType: selected.workspaceType, - workspaceName: selected.workspaceName, - status: selected.type === "existing" ? "connected" : undefined, - } - }) - const spinnerDef = createMemo(() => { const agent = status().type !== "idle" @@ -1474,6 +1353,7 @@ export function Prompt(props: PromptProps) { } }) const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3))) + const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48))) return ( <> @@ -1717,25 +1597,25 @@ export function Prompt(props: PromptProps) { - + {(notice) => ( {notice()} )} - - {(workspace) => ( + + {(label) => ( - + - + {(() => { - const item = workspace() + const item = label() if (item.type === "new") { - if (workspaceCreating()) - return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}` + if (workspace.creating()) + return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}` return ( <> Workspace (new {item.workspaceType}) @@ -1752,6 +1632,21 @@ export function Prompt(props: PromptProps) { )} + + {(progress) => ( + + + {progress()} + {".".repeat(move.creatingDots())} + + + )} + + + + (new working copy) + + {props.hint ?? } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx new file mode 100644 index 000000000000..780bbb92f11e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx @@ -0,0 +1,158 @@ +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import path from "path" +import { Global } from "@opencode-ai/core/global" +import { errorMessage } from "@/util/error" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { useToast } from "@tui/ui/toast" +import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" +import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" +import { useHomeSessionDestination } from "../../routes/home/session-destination" + +export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + const toast = useToast() + const homeDestination = useHomeSessionDestination() + const [creating, setCreating] = createSignal(false) + const [creatingDots, setCreatingDots] = createSignal(3) + const [progress, setProgress] = createSignal() + + async function create(context?: string) { + const projectID = input.projectID() + if (!projectID) return + setCreating(true) + setProgress("Creating copy") + try { + const result = await sdk.client.experimental.projectCopy.create( + { + projectID, + strategy: "git_worktree", + directory: path.join(Global.Path.data, "worktree", projectID.slice(0, 6)), + context, + }, + { throwOnError: true }, + ) + const directory = result.data?.directory + if (!directory) throw new Error("No project copy directory returned") + setProgress("Creating session") + return directory + } catch (err) { + homeDestination?.clear() + setProgress(undefined) + setCreating(false) + toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" }) + return + } + } + + function open() { + const projectID = input.projectID() + if (!projectID) return + dialog.replace(() => ( + { + const sessionID = input.sessionID() + if (!sessionID) { + homeDestination?.setDestination(selection) + dialog.clear() + return + } + void moveExistingSession(sessionID, selection) + }} + /> + )) + } + + function sessionContext(sessionID: string) { + const session = sync.session.get(sessionID) + const messages = (sync.data.message[sessionID] ?? []) + .slice(-6) + .map((message) => + [ + message.role + ":", + ...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])), + ].join(" "), + ) + return [session?.title, ...messages].filter(Boolean).join("\n") || undefined + } + + async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { + const session = sync.session.get(sessionID) + const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined) + const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" + if (!choice) return + dialog.clear() + const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory + if (!directory) { + setProgress(undefined) + dialog.clear() + return + } + setProgress("Moving session") + await sdk.client.experimental.controlPlane + .moveSession( + { + sessionID, + destination: { directory }, + moveChanges: choice === "yes", + }, + { throwOnError: true }, + ) + .then(() => dialog.clear()) + .catch((error) => { + toast.error(error) + dialog.clear() + }) + .finally(() => { + setProgress(undefined) + setCreating(false) + }) + } + + const pending = createMemo(() => Boolean(homeDestination?.destination())) + const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") + + async function getDirectory(context?: string) { + const value = homeDestination?.destination() + if (!value) return + if (value.type === "directory") { + return value.directory + } + return await create(context) + } + + function startSubmit() { + if (progress()) setProgress("Submitting prompt") + } + + function finishSubmit() { + homeDestination?.clear() + setProgress(undefined) + setCreating(false) + } + + createEffect(() => { + if (!creating()) { + setCreatingDots(3) + return + } + const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) + + return { + creating, + creatingDots, + finishSubmit, + getDirectory, + open, + pending, + pendingNew, + progress, + startSubmit, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx new file mode 100644 index 000000000000..bd19eee66cc7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx @@ -0,0 +1,137 @@ +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" +import { useProject } from "@tui/context/project" +import { useSync } from "@tui/context/sync" +import { useToast } from "@tui/ui/toast" +import { errorMessage } from "@/util/error" +import { + confirmWorkspaceFileChanges, + openWorkspaceSelect, + warpWorkspaceSession, + type WorkspaceSelection, +} from "../dialog-workspace-create" +import type { WorkspaceStatus } from "../workspace-label" + +export function usePromptWorkspace(sessionID?: string) { + const dialog = useDialog() + const sdk = useSDK() + const project = useProject() + const sync = useSync() + const toast = useToast() + const [selection, setSelection] = createSignal() + const [creating, setCreating] = createSignal(false) + const [creatingDots, setCreatingDots] = createSignal(3) + const [notice, setNotice] = createSignal() + + async function create(selection: Extract) { + setCreating(true) + let result + try { + result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) + } catch (err) { + setSelection(undefined) + setCreating(false) + toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" }) + return + } + if (result.error || !result.data) { + setSelection(undefined) + setCreating(false) + toast.show({ + title: "Creating workspace failed", + message: errorMessage(result.error ?? "no response"), + variant: "error", + }) + return + } + + await project.workspace.sync() + const workspace = result.data + setSelection({ + type: "existing", + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, + }) + setCreating(false) + return workspace + } + + async function warp(selection: WorkspaceSelection) { + if (!sessionID) { + setSelection(selection) + dialog.clear() + if (selection.type === "new") void create(selection) + return + } + const sourceWorkspaceID = project.workspace.current() + const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) + if (copyChanges === undefined) return + setSelection(selection) + dialog.clear() + + const workspace = + selection.type === "none" + ? { id: null, name: "local project" } + : selection.type === "existing" + ? { id: selection.workspaceID, name: selection.workspaceName } + : await create(selection) + if (!workspace) return + + const warped = await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + sourceWorkspaceID, + workspaceID: workspace.id, + sessionID, + copyChanges, + }) + if (warped) showNotice(workspace.name) + } + + function showNotice(name: string) { + setNotice(`Warped to ${name}`) + setTimeout(() => setNotice(undefined), 4000) + } + + function clearNotice() { + setNotice(undefined) + } + + function open() { + void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp }) + } + + createEffect(() => { + if (!creating()) { + setCreatingDots(3) + return + } + const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) + + const label = createMemo< + | { type: "new"; workspaceType: string } + | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } + | undefined + >(() => { + const selected = selection() + if (!selected) return + if (selected.type === "none") return + if (sessionID && !creating()) return + if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType } + return { + type: "existing", + workspaceType: selected.workspaceType, + workspaceName: selected.workspaceName, + status: selected.type === "existing" ? "connected" : undefined, + } + }) + + return { selection, creating, creatingDots, notice, label, open, warp, clearNotice } +} diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index f69cb68fd8a1..3e50237fc33f 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -204,6 +204,7 @@ export const Definitions = { "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), + "dialog.move_session.new": keybind("ctrl+w", "New project copy"), "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 4b2b0932f6d5..436ba5c51acb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -253,6 +253,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "session.next.moved": { + const result = Binary.search(store.session, event.properties.sessionID, (s) => s.id) + if (!result.found) break + setStore( + "session", + result.index, + produce((session) => { + session.directory = event.properties.location.directory + session.path = event.properties.subdirectory + session.workspaceID = event.properties.location.workspaceID + session.time.updated = event.properties.timestamp + }), + ) + break + } + case "session.status": { setStore("session_status", event.properties.sessionID, event.properties.status) break diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx index ea9c966bc92a..f2071637348c 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx @@ -2,12 +2,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, Match, Show, Switch } from "solid-js" import { Global } from "@opencode-ai/core/global" +import { useHomeSessionDestination } from "../../routes/home/session-destination" const id = "internal:home-footer" function Directory(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current + const destination = useHomeSessionDestination() const dir = createMemo(() => { + const selected = destination?.destination() + if (selected?.type === "new") return + if (selected?.type === "directory") return selected.directory.replace(Global.Path.home, "~") const dir = props.api.state.path.directory || process.cwd() const out = dir.replace(Global.Path.home, "~") const branch = props.api.state.vcs?.branch @@ -15,7 +20,7 @@ function Directory(props: { api: TuiPluginApi }) { return out }) - return {dir()} + return {(value) => {value()}} } function Mcp(props: { api: TuiPluginApi }) { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx index 1bdffa48651b..66b6c373520d 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx @@ -5,7 +5,7 @@ import { Global } from "@opencode-ai/core/global" const id = "internal:sidebar-footer" -function View(props: { api: TuiPluginApi }) { +function View(props: { api: TuiPluginApi; sessionID: string }) { const theme = () => props.api.theme.current const has = createMemo(() => props.api.state.provider.some( @@ -15,9 +15,11 @@ function View(props: { api: TuiPluginApi }) { const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const dir = props.api.state.path.directory || process.cwd() + const session = props.api.state.session.get(props.sessionID) + const dir = session?.directory || props.api.state.path.directory || process.cwd() const out = dir.replace(Global.Path.home, "~") - const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out + const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined + const text = branch ? out + ":" + branch : out const list = text.split("/") return { parent: list.slice(0, -1).join("/"), @@ -79,8 +81,8 @@ const tui: TuiPlugin = async (api) => { api.slots.register({ order: 100, slots: { - sidebar_footer() { - return + sidebar_footer(_ctx, props) { + return }, }, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 16797573c007..973dd1562c2d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -11,6 +11,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { useEditorContext } from "@tui/context/editor" import { useTerminalDimensions } from "@opentui/solid" import { useTuiConfig } from "../context/tui-config" +import { HomeSessionDestinationProvider } from "./home/session-destination" let once = false const placeholder = { @@ -66,7 +67,7 @@ export function Home() { }) return ( - <> + @@ -88,6 +89,6 @@ export function Home() { - + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx b/packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx new file mode 100644 index 000000000000..9b5bef6dbeda --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx @@ -0,0 +1,26 @@ +import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js" + +export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" } + +type Context = { + destination: Accessor + setDestination: Setter + clear: () => void +} + +const HomeSessionDestinationContext = createContext() + +export function HomeSessionDestinationProvider(props: ParentProps) { + const [destination, setDestination] = createSignal() + return ( + setDestination(undefined) }} + > + {props.children} + + ) +} + +export function useHomeSessionDestination() { + return useContext(HomeSessionDestinationContext) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index ec4cc750c6d3..d5318a88b92f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -23,6 +23,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string placeholder?: string + footer?: JSX.Element options: DialogSelectOption[] flat?: boolean ref?: (ref: DialogSelectRef) => void @@ -49,10 +50,13 @@ export interface DialogSelectProps { export interface DialogSelectOption { title: string + titleView?: JSX.Element value: T description?: string details?: string[] footer?: JSX.Element | string + titleWidth?: number + truncateTitle?: boolean | "left" category?: string categoryView?: JSX.Element disabled?: boolean @@ -472,7 +476,10 @@ export function DialogSelect(props: DialogSelectProps) {