mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:18:31 +00:00
feat(core): moving sessions (#30640)
This commit is contained in:
parent
e45e0e1125
commit
ba1b660d57
36 changed files with 2189 additions and 485 deletions
128
packages/core/src/control-plane/move-session.ts
Normal file
128
packages/core/src/control-plane/move-session.ts
Normal file
|
|
@ -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<DestinationProjectMismatchError>()(
|
||||
"MoveSession.DestinationProjectMismatchError",
|
||||
{
|
||||
expected: ProjectV2.ID,
|
||||
actual: ProjectV2.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
|
||||
"MoveSession.CaptureChangesError",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
|
||||
"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<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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),
|
||||
)
|
||||
|
|
@ -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<WorktreeError>()("Git
|
|||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("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<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
|
|
@ -52,6 +59,10 @@ export interface Interface {
|
|||
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
|
||||
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
|
||||
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<SessionSchema.Info[]>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
|
||||
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
247
packages/core/test/move-session.test.ts
Normal file
247
packages/core/test/move-session.test.ts
Normal file
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<DialogSelectOption<string | undefined>[]>(() => {
|
||||
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)}
|
||||
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
value: item.location,
|
||||
category: item.root === data?.main ? "Project" : "Working copies",
|
||||
titleWidth,
|
||||
truncateTitle: "left" as const,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => dialog.setSize("xlarge"))
|
||||
|
||||
return (
|
||||
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
|
||||
<DialogSelect
|
||||
title="Move session"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value) props.onSelect({ type: "directory", directory: option.value })
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
onTrigger: () => props.onSelect({ type: "new" }),
|
||||
},
|
||||
]}
|
||||
footer={
|
||||
<Show when={refreshing()}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>⬝</text>}>
|
||||
<spinner color={theme.textMuted} frames={REFRESH_FRAMES} interval={160} />
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>refreshing</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ export function DialogWorkspaceFileChanges(props: {
|
|||
</scrollbox>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Do you want to apply these changes after warping?
|
||||
Do you want to move these changes with the session?
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||
|
|
|
|||
|
|
@ -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<AutocompleteRef>()
|
||||
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
|
||||
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
||||
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
||||
const [warpNotice, setWarpNotice] = createSignal<string>()
|
||||
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<WorkspaceSelection, { type: "new" }>) {
|
||||
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(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
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) {
|
|||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={warpNotice()}>
|
||||
<Match when={workspace.notice()}>
|
||||
{(notice) => (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>{notice()}</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={workspaceLabel()}>
|
||||
{(workspace) => (
|
||||
<Match when={workspace.label()}>
|
||||
{(label) => (
|
||||
<box paddingLeft={3} flexDirection="row" gap={1}>
|
||||
<Show when={workspaceCreating()}>
|
||||
<Show when={workspace.creating()}>
|
||||
<Spinner color={theme.accent} />
|
||||
</Show>
|
||||
<text fg={workspaceCreating() ? theme.accent : theme.text}>
|
||||
<text fg={workspace.creating() ? theme.accent : theme.text}>
|
||||
{(() => {
|
||||
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 <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
|
||||
|
|
@ -1752,6 +1632,21 @@ export function Prompt(props: PromptProps) {
|
|||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3}>
|
||||
<Spinner color={theme.accent}>
|
||||
{progress()}
|
||||
<span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>(new working copy)</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||
</Switch>
|
||||
<Show when={status().type !== "retry"}>
|
||||
|
|
|
|||
158
packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx
Normal file
158
packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx
Normal file
|
|
@ -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<string>()
|
||||
|
||||
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(() => (
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
onSelect={(selection) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
137
packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx
Normal file
137
packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx
Normal file
|
|
@ -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<WorkspaceSelection>()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [notice, setNotice] = createSignal<string>()
|
||||
|
||||
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <text fg={theme().textMuted}>{dir()}</text>
|
||||
return <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
|
||||
}
|
||||
|
||||
function Mcp(props: { api: TuiPluginApi }) {
|
||||
|
|
|
|||
|
|
@ -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 <View api={api} />
|
||||
sidebar_footer(_ctx, props) {
|
||||
return <View api={api} sessionID={props.session_id} />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<HomeSessionDestinationProvider>
|
||||
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
|
|
@ -88,6 +89,6 @@ export function Home() {
|
|||
<box width="100%" flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
</box>
|
||||
</>
|
||||
</HomeSessionDestinationProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HomeSessionDestination | undefined>
|
||||
setDestination: Setter<HomeSessionDestination | undefined>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const HomeSessionDestinationContext = createContext<Context>()
|
||||
|
||||
export function HomeSessionDestinationProvider(props: ParentProps) {
|
||||
const [destination, setDestination] = createSignal<HomeSessionDestination>()
|
||||
return (
|
||||
<HomeSessionDestinationContext.Provider
|
||||
value={{ destination, setDestination, clear: () => setDestination(undefined) }}
|
||||
>
|
||||
{props.children}
|
||||
</HomeSessionDestinationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useHomeSessionDestination() {
|
||||
return useContext(HomeSessionDestinationContext)
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
|||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
placeholder?: string
|
||||
footer?: JSX.Element
|
||||
options: DialogSelectOption<T>[]
|
||||
flat?: boolean
|
||||
ref?: (ref: DialogSelectRef<T>) => void
|
||||
|
|
@ -49,10 +50,13 @@ export interface DialogSelectProps<T> {
|
|||
|
||||
export interface DialogSelectOption<T = any> {
|
||||
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<T>(props: DialogSelectProps<T>) {
|
|||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
titleView={option.titleView}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
titleWidth={option.titleWidth}
|
||||
truncateTitle={option.truncateTitle}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
|
|
@ -498,7 +505,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
|
||||
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
|
||||
<box
|
||||
paddingRight={2}
|
||||
paddingLeft={4}
|
||||
|
|
@ -508,6 +515,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
paddingTop={1}
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
{props.footer}
|
||||
<For each={left()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
|
|
@ -539,10 +547,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
|
||||
function Option(props: {
|
||||
title: string
|
||||
titleView?: JSX.Element
|
||||
description?: string
|
||||
active?: boolean
|
||||
current?: boolean
|
||||
footer?: JSX.Element | string
|
||||
titleWidth?: number
|
||||
truncateTitle?: boolean | "left"
|
||||
gutter?: () => JSX.Element
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
|
|
@ -569,7 +580,12 @@ function Option(props: {
|
|||
wrapMode="none"
|
||||
paddingLeft={3}
|
||||
>
|
||||
{Locale.truncate(props.title, 61)}
|
||||
{props.titleView ??
|
||||
(props.truncateTitle === false
|
||||
? props.title
|
||||
: props.truncateTitle === "left"
|
||||
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
|
||||
: Locale.truncate(props.title, props.titleWidth ?? 61))}
|
||||
<Show when={props.description}>
|
||||
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { InstanceDisposed } from "@/server/event"
|
|||
import { Question } from "@/question"
|
||||
import { ConfigApi } from "./groups/config"
|
||||
import { ControlApi } from "./groups/control"
|
||||
import { ControlPlaneApi } from "./groups/control-plane"
|
||||
import { EventApi } from "./groups/event"
|
||||
import { ExperimentalApi } from "./groups/experimental"
|
||||
import { FileApi } from "./groups/file"
|
||||
|
|
@ -42,6 +43,7 @@ const EventSchema = Schema.Union([
|
|||
|
||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||
.addHttpApi(ControlApi)
|
||||
.addHttpApi(ControlPlaneApi)
|
||||
.addHttpApi(GlobalApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
.middleware(Authorization)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/control-plane"
|
||||
export const MoveSessionPayload = Schema.Struct({ ...MoveSession.Input.fields })
|
||||
|
||||
export class ApiMoveSessionError extends Schema.ErrorClass<ApiMoveSessionError>("MoveSessionError")(
|
||||
{
|
||||
name: Schema.Literal("MoveSessionError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
export const ControlPlaneApi = HttpApi.make("controlPlane").add(
|
||||
HttpApiGroup.make("controlPlane")
|
||||
.add(
|
||||
HttpApiEndpoint.post("moveSession", `${root}/move-session`, {
|
||||
payload: MoveSessionPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session moved"),
|
||||
error: ApiMoveSessionError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.controlPlane.moveSession",
|
||||
summary: "Move session",
|
||||
description: "Move a session to another project directory, optionally transferring local changes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "controlPlane", description: "Control-plane orchestration routes." })),
|
||||
)
|
||||
|
|
@ -1,31 +1,50 @@
|
|||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/project/:projectID/copy"
|
||||
const CreateQuery = Schema.Struct({
|
||||
workspace: WorkspaceRoutingQueryFields.workspace,
|
||||
})
|
||||
|
||||
export const CreatePayload = Schema.Struct({
|
||||
strategy: ProjectCopy.StrategyID,
|
||||
directory: ProjectCopy.CreateInput.fields.directory,
|
||||
name: ProjectCopy.CreateInput.fields.name,
|
||||
context: ProjectCopy.CreateInput.fields.context,
|
||||
})
|
||||
export const RemovePayload = Schema.Struct({
|
||||
directory: ProjectCopy.RemoveInput.fields.directory,
|
||||
})
|
||||
|
||||
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
|
||||
{
|
||||
name: Schema.Literal("ProjectCopyError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
|
||||
HttpApiGroup.make("projectCopy")
|
||||
.add(
|
||||
HttpApiEndpoint.post("create", root, {
|
||||
params: { projectID: ProjectV2.ID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
query: CreateQuery,
|
||||
payload: CreatePayload,
|
||||
success: described(ProjectCopy.Copy, "Project copy created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
error: ApiProjectCopyError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.projectCopy.create",
|
||||
|
|
@ -38,7 +57,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
|
|||
query: WorkspaceRoutingQuery,
|
||||
payload: RemovePayload,
|
||||
success: described(HttpApiSchema.NoContent, "Project copy removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
error: ApiProjectCopyError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.projectCopy.remove",
|
||||
|
|
@ -51,7 +70,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
|
|||
query: WorkspaceRoutingQuery,
|
||||
payload: HttpApiSchema.NoContent,
|
||||
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
error: ApiProjectCopyError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.projectCopy.refresh",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { RootHttpApi } from "../api"
|
||||
import { ApiMoveSessionError, MoveSessionPayload } from "../groups/control-plane"
|
||||
|
||||
export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPlane", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MoveSession.Service
|
||||
|
||||
const moveSession = Effect.fn("ControlPlaneHttpApi.moveSession")(function* (ctx: {
|
||||
payload: typeof MoveSessionPayload.Type
|
||||
}) {
|
||||
yield* service.moveSession(ctx.payload).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ApiMoveSessionError({
|
||||
name: "MoveSessionError",
|
||||
data: { message: message(error) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("moveSession", moveSession)
|
||||
}),
|
||||
)
|
||||
|
||||
function message(error: MoveSession.Error) {
|
||||
if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
|
||||
if (error instanceof MoveSession.DestinationProjectMismatchError)
|
||||
return "Destination directory belongs to another project"
|
||||
return error.message
|
||||
}
|
||||
|
|
@ -2,26 +2,104 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
|||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CreatePayload, RemovePayload } from "../groups/project-copy"
|
||||
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
|
||||
const FALLBACK_AGENT: Agent.Info = {
|
||||
name: "title",
|
||||
mode: "primary" as const,
|
||||
permission: [],
|
||||
options: {},
|
||||
native: true,
|
||||
prompt: "",
|
||||
}
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
||||
return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ApiProjectCopyError({
|
||||
name: "ProjectCopyError",
|
||||
data: { message: message(error) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* LLM.Service
|
||||
const agent = yield* Agent.Service
|
||||
const provider = yield* Provider.Service
|
||||
const service = yield* ProjectCopy.Service
|
||||
|
||||
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
|
||||
const text = context?.trim()
|
||||
if (!text) return Slug.create()
|
||||
const [titleAgent, fallback] = yield* Effect.all(
|
||||
[
|
||||
agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
|
||||
provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (!fallback) return Slug.create()
|
||||
const model = titleAgent.model
|
||||
? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
|
||||
: ((yield* provider.getSmallModel(fallback.providerID)) ??
|
||||
(yield* provider.getModel(fallback.providerID, fallback.modelID)))
|
||||
const sessionID = SessionID.descending()
|
||||
const result = yield* llm
|
||||
.stream({
|
||||
agent: titleAgent,
|
||||
user: {
|
||||
id: MessageID.ascending(),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: titleAgent.name,
|
||||
model: { providerID: model.providerID, modelID: model.id },
|
||||
},
|
||||
system: [],
|
||||
small: true,
|
||||
tools: {},
|
||||
model,
|
||||
sessionID,
|
||||
retries: 2,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Generate a short filesystem-safe name for a project working copy based on this context:\n${text}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter(LLMEvent.is.textDelta),
|
||||
Stream.map((event) => event.text),
|
||||
Stream.mkString,
|
||||
)
|
||||
return slugify(result) || Slug.create()
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
|
||||
params: { projectID: ProjectV2.ID }
|
||||
payload: typeof CreatePayload.Type
|
||||
}) {
|
||||
const name =
|
||||
ctx.payload.name ??
|
||||
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
|
||||
return yield* badRequest(
|
||||
service.create({
|
||||
...ctx.payload,
|
||||
name,
|
||||
projectID: ctx.params.projectID,
|
||||
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
|
||||
}),
|
||||
|
|
@ -51,3 +129,24 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
|||
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
|
||||
}),
|
||||
)
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { Plugin } from "@/plugin"
|
|||
import { Project } from "@/project/project"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
|
@ -35,6 +36,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
|||
import { Question } from "@/question"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
|
|
@ -70,6 +72,7 @@ import { PtyConnectApi } from "./groups/pty"
|
|||
import { eventHandlers } from "./handlers/event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
import { controlPlaneHandlers } from "./handlers/control-plane"
|
||||
import { experimentalHandlers } from "./handlers/experimental"
|
||||
import { fileHandlers } from "./handlers/file"
|
||||
import { globalHandlers } from "./handlers/global"
|
||||
|
|
@ -120,7 +123,7 @@ const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provi
|
|||
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(httpApiAuthLayer),
|
||||
)
|
||||
|
|
@ -215,6 +218,7 @@ export function createRoutes(
|
|||
Config.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
MCP.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
|
|
@ -223,6 +227,7 @@ export function createRoutes(
|
|||
Project.defaultLayer,
|
||||
ProjectV2.defaultLayer,
|
||||
ProjectCopy.defaultLayer,
|
||||
MoveSession.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
|
|
|
|||
63
packages/opencode/test/server/httpapi-control-plane.test.ts
Normal file
63
packages/opencode/test/server/httpapi-control-plane.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Option, Ref } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
|
||||
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
|
||||
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
|
||||
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
|
||||
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const input = MoveSession.Input.make({
|
||||
sessionID: SessionV2.ID.make("ses_move"),
|
||||
destination: { directory: AbsolutePath.make("/destination") },
|
||||
moveChanges: true,
|
||||
})
|
||||
const called = Ref.makeUnsafe<MoveSession.Input | undefined>(undefined)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide([authorizationLayer, schemaErrorLayer]),
|
||||
// Raw HttpApi routes expose an opaque handler context at the request boundary.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
|
||||
),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provide(Layer.mock(Auth.Service)({})),
|
||||
Layer.provide(Layer.mock(Config.Service)({})),
|
||||
Layer.provide(Layer.mock(Installation.Service)({})),
|
||||
Layer.provide(
|
||||
Layer.mock(MoveSession.Service)({
|
||||
moveSession: (value) => Ref.set(called, value),
|
||||
}),
|
||||
),
|
||||
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
|
||||
)
|
||||
const it = testEffect(apiLayer)
|
||||
|
||||
describe("control-plane HttpApi", () => {
|
||||
it.live("moves a session through the root control-plane route", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe(
|
||||
HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(yield* Ref.get(called)).toEqual(input)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -506,6 +506,14 @@ const scenarios: Scenario[] = [
|
|||
body: {},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/experimental/control-plane/move-session", "experimental.controlPlane.moveSession")
|
||||
.global()
|
||||
.at(() => ({
|
||||
path: "/experimental/control-plane/move-session",
|
||||
body: {},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.get("/experimental/tool", "tool.list")
|
||||
.at((ctx) => ({
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
|
|||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
|
||||
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
|
||||
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
|
||||
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
|
||||
|
|
@ -17,7 +19,7 @@ import { testEffect } from "../lib/effect"
|
|||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide([authorizationLayer, schemaErrorLayer]),
|
||||
// Raw HttpApi routes expose an opaque handler context at the request boundary.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
|
|
@ -28,6 +30,7 @@ const apiLayer = HttpRouter.serve(
|
|||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provide(Layer.mock(Auth.Service)({})),
|
||||
Layer.provide(Layer.mock(Config.Service)({})),
|
||||
Layer.provide(Layer.mock(MoveSession.Service)({})),
|
||||
Layer.provide(
|
||||
Layer.mock(Installation.Service)({
|
||||
method: () => Effect.succeed("npm"),
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ describe("project directories and copies endpoints", () => {
|
|||
const projectID = (yield* json<{ id: string }>(current)).id
|
||||
const base = `/project/${projectID}`
|
||||
const copies = `/experimental/project/${projectID}/copy`
|
||||
const createdDirectory = 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")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(createdDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
Effect.promise(() => fs.rm(createdParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const initial = yield* request(test.directory, `${base}/directories`)
|
||||
|
|
@ -55,11 +56,11 @@ describe("project directories and copies endpoints", () => {
|
|||
const create = yield* request(test.directory, copies, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ strategy: "git_worktree", directory: createdDirectory }),
|
||||
body: JSON.stringify({ strategy: "git_worktree", directory: createdParent, name: "copy" }),
|
||||
})
|
||||
expect(create.status).toBe(200)
|
||||
const created = yield* json<{ directory: string }>(create)
|
||||
expect(created.directory).toContain("-http-copy")
|
||||
expect(created.directory).toBe(createdDirectory)
|
||||
|
||||
const listed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<string[]>(listed)).toContain(created.directory)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import type {
|
|||
ExperimentalConsoleListOrgsErrors,
|
||||
ExperimentalConsoleListOrgsResponses,
|
||||
ExperimentalConsoleSwitchOrgResponses,
|
||||
ExperimentalControlPlaneMoveSessionErrors,
|
||||
ExperimentalControlPlaneMoveSessionResponses,
|
||||
ExperimentalProjectCopyCreateErrors,
|
||||
ExperimentalProjectCopyCreateResponses,
|
||||
ExperimentalProjectCopyRefreshErrors,
|
||||
|
|
@ -108,6 +110,7 @@ import type {
|
|||
McpRemoteConfig,
|
||||
McpStatusErrors,
|
||||
McpStatusResponses,
|
||||
MoveSessionDestination,
|
||||
OutputFormat,
|
||||
Part as Part2,
|
||||
PartDeleteErrors,
|
||||
|
|
@ -532,33 +535,38 @@ export class App extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Config extends HeyApiClient {
|
||||
export class ControlPlane extends HeyApiClient {
|
||||
/**
|
||||
* Get global configuration
|
||||
* Move session
|
||||
*
|
||||
* Retrieve the current global OpenCode configuration settings and preferences.
|
||||
* Move a session to another project directory, optionally transferring local changes.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
|
||||
url: "/global/config",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global configuration
|
||||
*
|
||||
* Update global OpenCode configuration settings and preferences.
|
||||
*/
|
||||
public update<ThrowOnError extends boolean = false>(
|
||||
public moveSession<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
config?: Config3
|
||||
sessionID?: string
|
||||
destination?: MoveSessionDestination
|
||||
moveChanges?: boolean
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
|
||||
return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
|
||||
url: "/global/config",
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "body", key: "sessionID" },
|
||||
{ in: "body", key: "destination" },
|
||||
{ in: "body", key: "moveChanges" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
ExperimentalControlPlaneMoveSessionResponses,
|
||||
ExperimentalControlPlaneMoveSessionErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/experimental/control-plane/move-session",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
|
|
@ -570,204 +578,6 @@ export class Config extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Global extends HeyApiClient {
|
||||
/**
|
||||
* Get health
|
||||
*
|
||||
* Get health information about the OpenCode server.
|
||||
*/
|
||||
public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
|
||||
url: "/global/health",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global events
|
||||
*
|
||||
* Subscribe to global events from the OpenCode system using server-sent events.
|
||||
*/
|
||||
public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
|
||||
url: "/global/event",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose instance
|
||||
*
|
||||
* Clean up and dispose all OpenCode instances, releasing all resources.
|
||||
*/
|
||||
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
|
||||
url: "/global/dispose",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade opencode
|
||||
*
|
||||
* Upgrade opencode to the specified version or latest if not specified.
|
||||
*/
|
||||
public upgrade<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
target?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
|
||||
return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
|
||||
url: "/global/upgrade",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private _config?: Config
|
||||
get config(): Config {
|
||||
return (this._config ??= new Config({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Event extends HeyApiClient {
|
||||
/**
|
||||
* Subscribe to events
|
||||
*
|
||||
* Get events
|
||||
*/
|
||||
public subscribe<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
|
||||
url: "/event",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Config2 extends HeyApiClient {
|
||||
/**
|
||||
* Get configuration
|
||||
*
|
||||
* Retrieve the current OpenCode configuration settings and preferences.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
|
||||
url: "/config",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*
|
||||
* Update OpenCode configuration settings and preferences.
|
||||
*/
|
||||
public update<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
config?: Config3
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ key: "config", map: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
|
||||
url: "/config",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List config providers
|
||||
*
|
||||
* Get a list of all configured AI providers and their default models.
|
||||
*/
|
||||
public providers<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
|
||||
url: "/config/providers",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Console extends HeyApiClient {
|
||||
/**
|
||||
* Get active Console provider metadata
|
||||
|
|
@ -1021,10 +831,11 @@ export class ProjectCopy extends HeyApiClient {
|
|||
public create<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
projectID: string
|
||||
query_directory?: string
|
||||
workspace?: string
|
||||
strategy?: "git_worktree"
|
||||
body_directory?: string
|
||||
directory?: string
|
||||
name?: string
|
||||
context?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
|
|
@ -1034,18 +845,11 @@ export class ProjectCopy extends HeyApiClient {
|
|||
{
|
||||
args: [
|
||||
{ in: "path", key: "projectID" },
|
||||
{
|
||||
in: "query",
|
||||
key: "query_directory",
|
||||
map: "directory",
|
||||
},
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "strategy" },
|
||||
{
|
||||
in: "body",
|
||||
key: "body_directory",
|
||||
map: "directory",
|
||||
},
|
||||
{ in: "body", key: "directory" },
|
||||
{ in: "body", key: "name" },
|
||||
{ in: "body", key: "context" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -1377,6 +1181,11 @@ export class Workspace extends HeyApiClient {
|
|||
}
|
||||
|
||||
export class Experimental extends HeyApiClient {
|
||||
private _controlPlane?: ControlPlane
|
||||
get controlPlane(): ControlPlane {
|
||||
return (this._controlPlane ??= new ControlPlane({ client: this.client }))
|
||||
}
|
||||
|
||||
private _console?: Console
|
||||
get console(): Console {
|
||||
return (this._console ??= new Console({ client: this.client }))
|
||||
|
|
@ -1403,6 +1212,242 @@ export class Experimental extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Config extends HeyApiClient {
|
||||
/**
|
||||
* Get global configuration
|
||||
*
|
||||
* Retrieve the current global OpenCode configuration settings and preferences.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
|
||||
url: "/global/config",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global configuration
|
||||
*
|
||||
* Update global OpenCode configuration settings and preferences.
|
||||
*/
|
||||
public update<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
config?: Config3
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
|
||||
return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
|
||||
url: "/global/config",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Global extends HeyApiClient {
|
||||
/**
|
||||
* Get health
|
||||
*
|
||||
* Get health information about the OpenCode server.
|
||||
*/
|
||||
public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
|
||||
url: "/global/health",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global events
|
||||
*
|
||||
* Subscribe to global events from the OpenCode system using server-sent events.
|
||||
*/
|
||||
public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
|
||||
url: "/global/event",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose instance
|
||||
*
|
||||
* Clean up and dispose all OpenCode instances, releasing all resources.
|
||||
*/
|
||||
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
|
||||
url: "/global/dispose",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade opencode
|
||||
*
|
||||
* Upgrade opencode to the specified version or latest if not specified.
|
||||
*/
|
||||
public upgrade<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
target?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
|
||||
return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
|
||||
url: "/global/upgrade",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private _config?: Config
|
||||
get config(): Config {
|
||||
return (this._config ??= new Config({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Event extends HeyApiClient {
|
||||
/**
|
||||
* Subscribe to events
|
||||
*
|
||||
* Get events
|
||||
*/
|
||||
public subscribe<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
|
||||
url: "/event",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Config2 extends HeyApiClient {
|
||||
/**
|
||||
* Get configuration
|
||||
*
|
||||
* Retrieve the current OpenCode configuration settings and preferences.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
|
||||
url: "/config",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*
|
||||
* Update OpenCode configuration settings and preferences.
|
||||
*/
|
||||
public update<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
config?: Config3
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ key: "config", map: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
|
||||
url: "/config",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List config providers
|
||||
*
|
||||
* Get a list of all configured AI providers and their default models.
|
||||
*/
|
||||
public providers<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
|
||||
url: "/config/providers",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Tool extends HeyApiClient {
|
||||
/**
|
||||
* List tools
|
||||
|
|
@ -5712,6 +5757,11 @@ export class OpencodeClient extends HeyApiClient {
|
|||
return (this._app ??= new App({ client: this.client }))
|
||||
}
|
||||
|
||||
private _experimental?: Experimental
|
||||
get experimental(): Experimental {
|
||||
return (this._experimental ??= new Experimental({ client: this.client }))
|
||||
}
|
||||
|
||||
private _global?: Global
|
||||
get global(): Global {
|
||||
return (this._global ??= new Global({ client: this.client }))
|
||||
|
|
@ -5727,11 +5777,6 @@ export class OpencodeClient extends HeyApiClient {
|
|||
return (this._config ??= new Config2({ client: this.client }))
|
||||
}
|
||||
|
||||
private _experimental?: Experimental
|
||||
get experimental(): Experimental {
|
||||
return (this._experimental ??= new Experimental({ client: this.client }))
|
||||
}
|
||||
|
||||
private _tool?: Tool
|
||||
get tool(): Tool {
|
||||
return (this._tool ??= new Tool({ client: this.client }))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type Event =
|
|||
| EventMessagePartRemoved
|
||||
| EventSessionNextAgentSwitched
|
||||
| EventSessionNextModelSwitched
|
||||
| EventSessionNextMoved
|
||||
| EventSessionNextPrompted
|
||||
| EventSessionNextSynthetic
|
||||
| EventSessionNextShellStarted
|
||||
|
|
@ -136,6 +137,13 @@ export type InvalidRequestError = {
|
|||
field?: string
|
||||
}
|
||||
|
||||
export type MoveSessionError = {
|
||||
name: "MoveSessionError"
|
||||
data: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SnapshotFileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
|
|
@ -812,6 +820,16 @@ export type GlobalEvent = {
|
|||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.moved"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
location: LocationRef
|
||||
subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.prompted"
|
||||
|
|
@ -1556,6 +1574,7 @@ export type GlobalEvent = {
|
|||
| SyncEventMessagePartRemoved
|
||||
| SyncEventSessionNextAgentSwitched
|
||||
| SyncEventSessionNextModelSwitched
|
||||
| SyncEventSessionNextMoved
|
||||
| SyncEventSessionNextPrompted
|
||||
| SyncEventSessionNextSynthetic
|
||||
| SyncEventSessionNextShellStarted
|
||||
|
|
@ -2397,6 +2416,13 @@ export type ProjectNotFoundError = {
|
|||
message: string
|
||||
}
|
||||
|
||||
export type ProjectCopyError = {
|
||||
name: "ProjectCopyError"
|
||||
data: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PtyNotFoundError = {
|
||||
_tag: "PtyNotFoundError"
|
||||
ptyID: string
|
||||
|
|
@ -2751,6 +2777,10 @@ export type EventTuiSessionSelect2 = {
|
|||
}
|
||||
}
|
||||
|
||||
export type MoveSessionDestination = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
export type ModelV2Info = {
|
||||
id: string
|
||||
providerID: string
|
||||
|
|
@ -2821,6 +2851,11 @@ export type ModelV2Info = {
|
|||
}
|
||||
}
|
||||
|
||||
export type LocationRef = {
|
||||
directory: string
|
||||
workspaceID?: string
|
||||
}
|
||||
|
||||
export type PromptSource = {
|
||||
start: number
|
||||
end: number
|
||||
|
|
@ -3112,6 +3147,23 @@ export type SyncEventSessionNextModelSwitched = {
|
|||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextMoved = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.next.moved.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
location: LocationRef
|
||||
subdirectory?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextPrompted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
|
|
@ -3588,11 +3640,6 @@ export type AgentV2Info = {
|
|||
permissions: PermissionV2Ruleset
|
||||
}
|
||||
|
||||
export type LocationRef = {
|
||||
directory: string
|
||||
workspaceID?: string
|
||||
}
|
||||
|
||||
export type SessionV2Info = {
|
||||
id: string
|
||||
parentID?: string
|
||||
|
|
@ -4147,6 +4194,17 @@ export type EventSessionNextModelSwitched = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextMoved = {
|
||||
id: string
|
||||
type: "session.next.moved"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
location: LocationRef
|
||||
subdirectory?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextPrompted = {
|
||||
id: string
|
||||
type: "session.next.prompted"
|
||||
|
|
@ -5002,6 +5060,37 @@ export type AppLogResponses = {
|
|||
|
||||
export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
|
||||
|
||||
export type ExperimentalControlPlaneMoveSessionData = {
|
||||
body?: {
|
||||
sessionID: string
|
||||
destination: MoveSessionDestination
|
||||
moveChanges?: boolean
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: "/experimental/control-plane/move-session"
|
||||
}
|
||||
|
||||
export type ExperimentalControlPlaneMoveSessionErrors = {
|
||||
/**
|
||||
* MoveSessionError | InvalidRequestError
|
||||
*/
|
||||
400: MoveSessionError | InvalidRequestError
|
||||
}
|
||||
|
||||
export type ExperimentalControlPlaneMoveSessionError =
|
||||
ExperimentalControlPlaneMoveSessionErrors[keyof ExperimentalControlPlaneMoveSessionErrors]
|
||||
|
||||
export type ExperimentalControlPlaneMoveSessionResponses = {
|
||||
/**
|
||||
* Session moved
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type ExperimentalControlPlaneMoveSessionResponse =
|
||||
ExperimentalControlPlaneMoveSessionResponses[keyof ExperimentalControlPlaneMoveSessionResponses]
|
||||
|
||||
export type GlobalHealthData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
|
@ -6596,9 +6685,9 @@ export type ExperimentalProjectCopyRemoveData = {
|
|||
|
||||
export type ExperimentalProjectCopyRemoveErrors = {
|
||||
/**
|
||||
* BadRequest | InvalidRequestError
|
||||
* ProjectCopyError | InvalidRequestError
|
||||
*/
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
400: ProjectCopyError | InvalidRequestError
|
||||
}
|
||||
|
||||
export type ExperimentalProjectCopyRemoveError =
|
||||
|
|
@ -6618,12 +6707,13 @@ export type ExperimentalProjectCopyCreateData = {
|
|||
body?: {
|
||||
strategy: "git_worktree"
|
||||
directory: string
|
||||
name?: string
|
||||
context?: string
|
||||
}
|
||||
path: {
|
||||
projectID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/experimental/project/{projectID}/copy"
|
||||
|
|
@ -6631,9 +6721,9 @@ export type ExperimentalProjectCopyCreateData = {
|
|||
|
||||
export type ExperimentalProjectCopyCreateErrors = {
|
||||
/**
|
||||
* BadRequest | InvalidRequestError
|
||||
* ProjectCopyError | InvalidRequestError
|
||||
*/
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
400: ProjectCopyError | InvalidRequestError
|
||||
}
|
||||
|
||||
export type ExperimentalProjectCopyCreateError =
|
||||
|
|
@ -6663,9 +6753,9 @@ export type ExperimentalProjectCopyRefreshData = {
|
|||
|
||||
export type ExperimentalProjectCopyRefreshErrors = {
|
||||
/**
|
||||
* BadRequest | InvalidRequestError
|
||||
* ProjectCopyError | InvalidRequestError
|
||||
*/
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
400: ProjectCopyError | InvalidRequestError
|
||||
}
|
||||
|
||||
export type ExperimentalProjectCopyRefreshError =
|
||||
|
|
|
|||
|
|
@ -212,6 +212,66 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/experimental/control-plane/move-session": {
|
||||
"post": {
|
||||
"tags": ["controlPlane"],
|
||||
"operationId": "experimental.controlPlane.moveSession",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Session moved"
|
||||
},
|
||||
"400": {
|
||||
"description": "MoveSessionError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MoveSessionError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Move a session to another project directory, optionally transferring local changes.",
|
||||
"summary": "Move session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"destination": {
|
||||
"$ref": "#/components/schemas/MoveSessionDestination"
|
||||
},
|
||||
"moveChanges": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "destination"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.controlPlane.moveSession({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/global/health": {
|
||||
"get": {
|
||||
"tags": ["global"],
|
||||
|
|
@ -3774,14 +3834,6 @@
|
|||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
|
|
@ -3803,13 +3855,13 @@
|
|||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"description": "ProjectCopyError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
"$ref": "#/components/schemas/ProjectCopyError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
|
|
@ -3834,6 +3886,12 @@
|
|||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"context": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
|
|
@ -3883,13 +3941,13 @@
|
|||
"description": "Project copy removed"
|
||||
},
|
||||
"400": {
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"description": "ProjectCopyError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
"$ref": "#/components/schemas/ProjectCopyError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
|
|
@ -3961,13 +4019,13 @@
|
|||
"description": "Project copies refreshed"
|
||||
},
|
||||
"400": {
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"description": "ProjectCopyError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
"$ref": "#/components/schemas/ProjectCopyError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
|
|
@ -11953,6 +12011,9 @@
|
|||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextModelSwitched"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextMoved"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextPrompted"
|
||||
},
|
||||
|
|
@ -12312,6 +12373,27 @@
|
|||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MoveSessionError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["MoveSessionError"]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SnapshotFileDiff": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -14356,6 +14438,40 @@
|
|||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.next.moved"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "number"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/LocationRef"
|
||||
},
|
||||
"subdirectory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "location"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -16782,6 +16898,9 @@
|
|||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextModelSwitched"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextMoved"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextPrompted"
|
||||
},
|
||||
|
|
@ -19094,6 +19213,27 @@
|
|||
"required": ["_tag", "projectID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProjectCopyError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["ProjectCopyError"]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PtyNotFoundError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -20117,6 +20257,16 @@
|
|||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MoveSessionDestination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ModelV2Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -20355,6 +20505,20 @@
|
|||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LocationRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspaceID": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PromptSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -21200,6 +21364,60 @@
|
|||
"required": ["type", "id", "syncEvent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncEventSessionNextMoved": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["sync"]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"syncEvent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.next.moved.1"]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"seq": {
|
||||
"type": "number"
|
||||
},
|
||||
"aggregateID": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "number"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/LocationRef"
|
||||
},
|
||||
"subdirectory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "location"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "seq", "aggregateID", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "syncEvent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncEventSessionNextPrompted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -22653,20 +22871,6 @@
|
|||
"required": ["id", "request", "mode", "hidden", "permissions"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LocationRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspaceID": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionV2Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -24279,6 +24483,40 @@
|
|||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventSessionNextMoved": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.next.moved"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "number"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/LocationRef"
|
||||
},
|
||||
"subdirectory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "location"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventSessionNextPrompted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -26564,6 +26802,10 @@
|
|||
"name": "control",
|
||||
"description": "Control plane routes."
|
||||
},
|
||||
{
|
||||
"name": "controlPlane",
|
||||
"description": "Control-plane orchestration routes."
|
||||
},
|
||||
{
|
||||
"name": "global",
|
||||
"description": "Global server routes."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue