mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 15:03:43 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edee757028 |
@@ -19,7 +19,7 @@ import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -60,7 +60,7 @@ const effectTypeReferences = [
|
||||
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
|
||||
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
|
||||
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
|
||||
...namespaceTypes("Worktree", "@opencode-ai/schema/worktree", Worktree),
|
||||
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
|
||||
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
|
||||
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
|
||||
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import type { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { Question } from "@opencode-ai/schema/question"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
@@ -1230,9 +1230,17 @@ export type Endpoint13_1Input = {
|
||||
export type Endpoint13_1Output = Project.Current
|
||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
||||
|
||||
export type Endpoint13_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint13_2Output = Project.Directories
|
||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
readonly directories: ProjectDirectoriesOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint14_0Input = {
|
||||
@@ -1541,36 +1549,36 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint24_1Input = {
|
||||
export type Endpoint24_0Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy: ProjectCopy.StrategyID
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint24_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
export type Endpoint24_0Output = ProjectCopy.Copy
|
||||
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
export type Endpoint24_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type Endpoint24_1Output = void
|
||||
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint24_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
|
||||
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
readonly list: WorktreeListOperation<E>
|
||||
readonly create: WorktreeCreateOperation<E>
|
||||
readonly remove: WorktreeRemoveOperation<E>
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
export interface ProjectCopyApi<E = never> {
|
||||
readonly create: ProjectCopyCreateOperation<E>
|
||||
readonly remove: ProjectCopyRemoveOperation<E>
|
||||
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Input = {
|
||||
@@ -1683,7 +1691,7 @@ export interface AppApi<E = never> {
|
||||
readonly shell: ShellApi<E>
|
||||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
|
||||
@@ -139,6 +139,8 @@ import type {
|
||||
Endpoint13_0Output,
|
||||
Endpoint13_1Input,
|
||||
Endpoint13_1Output,
|
||||
Endpoint13_2Input,
|
||||
Endpoint13_2Output,
|
||||
Endpoint14_0Input,
|
||||
Endpoint14_0Output,
|
||||
Endpoint14_1Input,
|
||||
@@ -214,8 +216,6 @@ import type {
|
||||
Endpoint24_1Output,
|
||||
Endpoint24_2Input,
|
||||
Endpoint24_2Output,
|
||||
Endpoint24_3Input,
|
||||
Endpoint24_3Output,
|
||||
Endpoint25_0Input,
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
@@ -876,7 +876,19 @@ const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) })
|
||||
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
|
||||
preserveEffect<Endpoint13_2Output>()(
|
||||
raw["project.directories"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
|
||||
list: Endpoint13_0(raw),
|
||||
current: Endpoint13_1(raw),
|
||||
directories: Endpoint13_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) =>
|
||||
preserveEffect<Endpoint14_0Output>()(
|
||||
@@ -1200,37 +1212,36 @@ const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23
|
||||
|
||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||
|
||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||
const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["worktree.create"]({
|
||||
raw["projectCopy.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["worktree.remove"]({
|
||||
const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["projectCopy.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||
preserveEffect<Endpoint24_3Output>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["projectCopy.refresh"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint24_0(raw),
|
||||
create: Endpoint24_1(raw),
|
||||
remove: Endpoint24_2(raw),
|
||||
refresh: Endpoint24_3(raw),
|
||||
const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
|
||||
create: Endpoint24_0(raw),
|
||||
remove: Endpoint24_1(raw),
|
||||
refresh: Endpoint24_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
@@ -1323,7 +1334,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
shell: adaptGroup21(raw["server.shell"]),
|
||||
question: adaptGroup22(raw["server.question"]),
|
||||
reference: adaptGroup23(raw["server.reference"]),
|
||||
worktree: adaptGroup24(raw["server.worktree"]),
|
||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
|
||||
@@ -33,7 +33,7 @@ export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
|
||||
@@ -133,6 +133,8 @@ import type {
|
||||
ProjectListOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectDirectoriesInput,
|
||||
ProjectDirectoriesOutput,
|
||||
FormRequestListInput,
|
||||
FormRequestListOutput,
|
||||
FormListInput,
|
||||
@@ -204,14 +206,12 @@ import type {
|
||||
QuestionRejectOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
WorktreeListInput,
|
||||
WorktreeListOutput,
|
||||
WorktreeCreateInput,
|
||||
WorktreeCreateOutput,
|
||||
WorktreeRemoveInput,
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
ProjectCopyCreateInput,
|
||||
ProjectCopyCreateOutput,
|
||||
ProjectCopyRemoveInput,
|
||||
ProjectCopyRemoveOutput,
|
||||
ProjectCopyRefreshInput,
|
||||
ProjectCopyRefreshOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
@@ -1255,6 +1255,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectDirectoriesOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
form: {
|
||||
request: {
|
||||
@@ -1724,23 +1736,13 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeCreateOutput>(
|
||||
projectCopy: {
|
||||
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1748,11 +1750,12 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: WorktreeRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRemoveOutput>(
|
||||
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1760,11 +1763,12 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -291,6 +291,8 @@ export type ProjectTime = { created: number; updated: number; initialized?: numb
|
||||
|
||||
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||
|
||||
export type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type FormMetadata = { [x: string]: JsonValue }
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
@@ -368,9 +370,7 @@ export type ReferenceGitSource = {
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type WorktreeDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type WorktreeInfo = { directory: string }
|
||||
export type ProjectCopyCopy = { directory: string }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
@@ -828,20 +828,20 @@ export type PluginUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type WorktreeUpdated = {
|
||||
export type ProjectDirectoriesUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "worktree.updated"
|
||||
type: "project.directories.updated"
|
||||
location?: LocationRef
|
||||
data: { projectID: string }
|
||||
}
|
||||
|
||||
export type WorktreeResolved = {
|
||||
export type ProjectDirectoryResolved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "worktree.resolved"
|
||||
type: "project.directory.resolved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { projectID: string; directory: string; previous: string }
|
||||
@@ -1369,6 +1369,8 @@ export type Project = {
|
||||
sandboxes: Array<string>
|
||||
}
|
||||
|
||||
export type ProjectDirectories = Array<ProjectDirectory>
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type PermissionRequest = {
|
||||
@@ -1518,8 +1520,6 @@ export type SessionStatus2 = {
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type WorktreeList = Array<WorktreeDirectory>
|
||||
|
||||
export type VcsInfo = { branch: VcsBranch }
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
@@ -2102,8 +2102,8 @@ export type V2Event =
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| WorktreeUpdated
|
||||
| WorktreeResolved
|
||||
| ProjectDirectoriesUpdated
|
||||
| ProjectDirectoryResolved
|
||||
| CommandUpdated
|
||||
| ConfigUpdated
|
||||
| SkillUpdated
|
||||
@@ -2304,12 +2304,12 @@ export type QuestionNotFoundError = {
|
||||
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
|
||||
|
||||
export type WorktreeError = {
|
||||
readonly name: "WorktreeError"
|
||||
export type ProjectCopyError = {
|
||||
readonly name: "ProjectCopyError"
|
||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||
}
|
||||
export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
|
||||
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
|
||||
|
||||
export type HealthGetOutput = ServiceHealth
|
||||
|
||||
@@ -4348,6 +4348,15 @@ export type ProjectCurrentInput = {
|
||||
|
||||
export type ProjectCurrentOutput = ProjectCurrent
|
||||
|
||||
export type ProjectDirectoriesInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ProjectDirectoriesOutput = ProjectDirectories
|
||||
|
||||
export type FormRequestListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -5633,30 +5642,37 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
export type ProjectCopyCreateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
|
||||
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
|
||||
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
|
||||
}
|
||||
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
export type ProjectCopyCreateOutput = ProjectCopyCopy
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
export type ProjectCopyRemoveInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type WorktreeRemoveOutput = void
|
||||
export type ProjectCopyRemoveOutput = void
|
||||
|
||||
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
export type ProjectCopyRefreshInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
export type ProjectCopyRefreshOutput = void
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: {
|
||||
|
||||
@@ -29,7 +29,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"shell",
|
||||
"question",
|
||||
"reference",
|
||||
"worktree",
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
@@ -49,8 +49,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
@@ -252,44 +251,30 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("worktree methods use the global project contract", async () => {
|
||||
const requests: Request[] = []
|
||||
test("project methods use the public HTTP contract", async () => {
|
||||
const requests: string[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
|
||||
if (request.method === "POST" && !request.url.endsWith("/refresh"))
|
||||
return Response.json({ directory: "/tmp/worktrees/api" })
|
||||
return new Response(null, { status: 204 })
|
||||
fetch: async (input) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
requests.push(url)
|
||||
if (url.includes("/directories")) return Response.json([])
|
||||
return Response.json({ id: "proj_test", directory: "/tmp/project" })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(
|
||||
await client.worktree.create({
|
||||
projectID: "proj_test",
|
||||
strategy: "git",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
}),
|
||||
).toEqual({ directory: "/tmp/worktrees/api" })
|
||||
await client.worktree.remove({
|
||||
projectID: "proj_test",
|
||||
directory: "/tmp/worktrees/api",
|
||||
force: false,
|
||||
const current = await client.project.current({ location: { workspace: "wrk_test" } })
|
||||
const directories = await client.project.directories({
|
||||
projectID: current.id,
|
||||
location: { directory: current.directory },
|
||||
})
|
||||
await client.worktree.refresh({ projectID: "proj_test" })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
|
||||
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
|
||||
expect(directories).toEqual([])
|
||||
expect(requests).toEqual([
|
||||
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
|
||||
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({ strategy: "git", directory: "/tmp/worktrees", name: "api" })
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
})
|
||||
|
||||
test("shell list and remove use the public HTTP contract", async () => {
|
||||
|
||||
+41
-191
@@ -1,10 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
|
||||
"prevIds": [
|
||||
"5c1aa56b-c3ee-4283-9a84-c0bf626dc604"
|
||||
],
|
||||
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
|
||||
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -78,10 +76,6 @@
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "worktree",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
@@ -1443,53 +1437,9 @@
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "strategy",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"active_account_id"
|
||||
],
|
||||
"columns": ["active_account_id"],
|
||||
"tableTo": "account",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "SET NULL",
|
||||
"nameExplicit": false,
|
||||
@@ -1498,13 +1448,9 @@
|
||||
"table": "account_state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columns": ["aggregate_id"],
|
||||
"tableTo": "event_sequence",
|
||||
"columnsTo": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columnsTo": ["aggregate_id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1513,13 +1459,9 @@
|
||||
"table": "event"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1528,13 +1470,9 @@
|
||||
"table": "permission"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1543,13 +1481,9 @@
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1558,13 +1492,9 @@
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1573,13 +1503,9 @@
|
||||
"table": "instruction_state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1588,13 +1514,9 @@
|
||||
"table": "session_inbox"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1603,13 +1525,9 @@
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1618,13 +1536,9 @@
|
||||
"table": "session_pending"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1633,190 +1547,126 @@
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_worktree_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"email",
|
||||
"url"
|
||||
],
|
||||
"columns": ["email", "url"],
|
||||
"nameExplicit": false,
|
||||
"name": "control_account_pk",
|
||||
"entityType": "pks",
|
||||
"table": "control_account"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"columns": ["project_id", "directory"],
|
||||
"nameExplicit": false,
|
||||
"name": "project_directory_pk",
|
||||
"entityType": "pks",
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id",
|
||||
"key"
|
||||
],
|
||||
"columns": ["session_id", "key"],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_entry_pk",
|
||||
"entityType": "pks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "worktree_pk",
|
||||
"entityType": "pks",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "account_state_pk",
|
||||
"table": "account_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "account_pk",
|
||||
"table": "account",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "credential_pk",
|
||||
"table": "credential",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columns": ["aggregate_id"],
|
||||
"nameExplicit": false,
|
||||
"name": "event_sequence_pk",
|
||||
"table": "event_sequence",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "event_pk",
|
||||
"table": "event",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"columns": ["key"],
|
||||
"nameExplicit": false,
|
||||
"name": "kv_pk",
|
||||
"table": "kv",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "permission_pk",
|
||||
"table": "permission",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "project_pk",
|
||||
"table": "project",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"hash"
|
||||
],
|
||||
"columns": ["hash"],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_blob_pk",
|
||||
"table": "instruction_blob",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_state_pk",
|
||||
"table": "instruction_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_inbox_pk",
|
||||
"table": "session_inbox",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pending_pk",
|
||||
"table": "session_pending",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_v2_pk",
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
@@ -2112,4 +1962,4 @@
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +122,7 @@ const layer = Layer.effect(
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
const agents = Array.fromIterable(state.get().agents.values())
|
||||
const selected = selectedDefault()
|
||||
if (!selected) return agents
|
||||
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
-2
@@ -42,7 +42,6 @@ import m39 from "./migration/20260805200742_import_legacy_credentials.js"
|
||||
import m40 from "./migration/20260808023530_workspace_domain.js"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
|
||||
import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -88,5 +87,4 @@ export const migrations = [
|
||||
m40,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260812213948_worktree",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`worktree\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`strategy\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`worktree\` (\`project_id\`, \`directory\`, \`strategy\`, \`time_created\`)
|
||||
SELECT
|
||||
\`project_id\`,
|
||||
\`directory\`,
|
||||
CASE
|
||||
WHEN \`strategy\` = 'git_worktree' THEN 'git'
|
||||
WHEN \`strategy\` IS NOT NULL THEN \`strategy\`
|
||||
WHEN \`type\` = 'git_worktree' THEN 'git'
|
||||
END,
|
||||
\`time_created\`
|
||||
FROM \`project_directory\`;
|
||||
`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -225,16 +225,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`last_used_at\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`worktree\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`strategy\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
@@ -22,64 +22,38 @@ export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
||||
|
||||
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||
|
||||
export const ripgrepLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const clock = yield* Clock.Clock
|
||||
const files: string[] = []
|
||||
const directories = new Set<string>()
|
||||
const home = Protected.isHome(location.directory)
|
||||
let index = { files: [] as string[], directories: new Set<string>() }
|
||||
let initialized = false
|
||||
let settledAt = Number.NEGATIVE_INFINITY
|
||||
let refreshing = false
|
||||
const scan = Effect.gen(function* () {
|
||||
const next = { files: [] as string[], directories: new Set<string>() }
|
||||
if (!initialized) index = next
|
||||
yield* ripgrep.find({
|
||||
yield* ripgrep
|
||||
.find({
|
||||
cwd: location.directory,
|
||||
pattern: "*",
|
||||
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
next.files.push(entry.path)
|
||||
files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts
|
||||
.slice(0, -1)
|
||||
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
|
||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||
}),
|
||||
})
|
||||
index = next
|
||||
initialized = true
|
||||
}).pipe(
|
||||
Effect.orDie,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
settledAt = clock.currentTimeMillisUnsafe()
|
||||
refreshing = false
|
||||
}),
|
||||
),
|
||||
)
|
||||
const refresh = Effect.sync(() => {
|
||||
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
|
||||
refreshing = true
|
||||
return scan
|
||||
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
|
||||
yield* refresh
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* refresh
|
||||
const items =
|
||||
input.type === "file"
|
||||
? index.files
|
||||
? files
|
||||
: input.type === "directory"
|
||||
? Array.from(index.directories)
|
||||
: [...index.files, ...index.directories]
|
||||
? Array.from(directories)
|
||||
: [...files, ...directories]
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as InstructionDiscovery from "./instruction-discovery.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { createPatch } from "diff"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Instructions } from "./instructions/index.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
@@ -82,7 +81,8 @@ export const layer = (options?: Options) =>
|
||||
read: Effect.succeed(value),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: renderUpdate,
|
||||
changed: (_previous, current) =>
|
||||
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
|
||||
removed: () => "Previously loaded instructions no longer apply.",
|
||||
},
|
||||
})
|
||||
@@ -120,27 +120,3 @@ export const node = configured()
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
}
|
||||
|
||||
function renderUpdate(previous: ReadonlyArray<File>, current: ReadonlyArray<File>) {
|
||||
const changes = Instructions.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(file) => file.path,
|
||||
(before, after) => before.content !== after.content,
|
||||
)
|
||||
return [
|
||||
...changes.removed.map((file) => `The instructions from ${file.path} no longer apply.`),
|
||||
...changes.added.map((file) => `New instructions apply from:\n${render([file])}`),
|
||||
...changes.changed.map(({ previous: before, current: after }) => {
|
||||
const patch = createPatch(after.path, before.content, after.content, "", "", { context: 3 })
|
||||
const diff = [
|
||||
`The instructions from ${after.path} changed. Here's the diff:`,
|
||||
"```diff",
|
||||
patch.slice(patch.indexOf("@@")).trimEnd(),
|
||||
"```",
|
||||
].join("\n")
|
||||
const replacement = `The instructions changed:\n${render([after])}`
|
||||
return diff.length < replacement.length ? diff : replacement
|
||||
}),
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { MCP } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { ProjectCopy } from "./project/copy.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Shell } from "./shell.js"
|
||||
@@ -66,7 +66,8 @@ const locationServiceNodes = [
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
Pty.node,
|
||||
|
||||
@@ -2,20 +2,20 @@ export * as Project from "./project.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { and, asc, desc, eq } from "drizzle-orm"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { ProjectDirectories } from "./project/directories.js"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { ProjectTable, upsertProject } from "./project/sql.js"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
|
||||
export const ID = ProjectSchema.ID
|
||||
export type ID = ProjectSchema.ID
|
||||
@@ -26,9 +26,18 @@ export type Vcs = ProjectSchema.Vcs
|
||||
export const Current = ProjectSchema.Current
|
||||
export type Current = ProjectSchema.Current
|
||||
|
||||
export const Directory = ProjectSchema.Directory
|
||||
export type Directory = ProjectSchema.Directory
|
||||
|
||||
export const Info = ProjectSchema.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const DirectoriesInput = ProjectSchema.DirectoriesInput
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = ProjectSchema.Directories
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
@@ -47,6 +56,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
@@ -85,19 +95,18 @@ const layer = Layer.effect(
|
||||
const proc = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const announcing = new Set<string>()
|
||||
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
if (!project.vcs) return project
|
||||
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
|
||||
{ projectID: project.id, directory: project.canonical },
|
||||
]
|
||||
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
|
||||
if (project.directory !== project.canonical)
|
||||
directories.push({
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git" : undefined,
|
||||
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
@@ -110,25 +119,11 @@ const layer = Layer.effect(
|
||||
if (announcing.has(key)) continue
|
||||
announcing.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const stored = yield* db
|
||||
.select({ directory: WorktreeTable.directory })
|
||||
.from(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, item.projectID), eq(WorktreeTable.directory, item.directory)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored) return
|
||||
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
|
||||
yield* bus.publish(
|
||||
Worktree.Event.Resolved,
|
||||
Event.Resolved,
|
||||
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
|
||||
{
|
||||
commit: () =>
|
||||
db
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: item.projectID, directory: item.directory, strategy: item.strategy })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
},
|
||||
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
|
||||
)
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
|
||||
}
|
||||
@@ -145,6 +140,10 @@ const layer = Layer.effect(
|
||||
return rows.map(fromRow)
|
||||
})
|
||||
|
||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||
return yield* projectDirectories.list(input.projectID)
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
@@ -258,12 +257,12 @@ const layer = Layer.effect(
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
return Service.of({ list, resolve })
|
||||
return Service.of({ list, directories, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { Git } from "../git.js"
|
||||
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy.js"
|
||||
|
||||
export function makeGitWorktreeStrategy(input: {
|
||||
git: Git.Interface
|
||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||
}) {
|
||||
return {
|
||||
id: StrategyID.make("git_worktree"),
|
||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||
const repository = yield* input.git.repo.discover(options.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
|
||||
yield* input.git.worktree.create({ repository, directory: options.directory })
|
||||
return { directory: yield* input.canonical(options.directory) }
|
||||
}),
|
||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
|
||||
const found = yield* input.git.repo.discover(options.directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
|
||||
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const found = yield* input.git.repo.discover(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
const entries = yield* input.git.worktree.list(found)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
input.canonical(entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
export * as ProjectCopy from "./copy.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Project } from "../project.js"
|
||||
import { ProjectDirectories } from "./directories.js"
|
||||
import { makeGitWorktreeStrategy } from "./copy-strategies.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
|
||||
export const StrategyID = ProjectCopy.StrategyID
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = ProjectCopy.CreateInput
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = ProjectCopy.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.Type
|
||||
|
||||
export const Copy = ProjectCopy.Copy
|
||||
export type Copy = typeof Copy.Type
|
||||
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "copy"]),
|
||||
}).annotate({ identifier: "ProjectCopy.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"ProjectCopy.SourceDirectoryNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
|
||||
"ProjectCopy.DestinationExistsError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
|
||||
"ProjectCopy.DirectoryUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||
"ProjectCopy.InvalidDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||
"ProjectCopy.StrategyUnavailableError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export { Event }
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const copies = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
|
||||
const result = yield* copies.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("project copy refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const directories = yield* ProjectDirectories.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(yield* fs.resolve(input))
|
||||
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const strategy = makeGitWorktreeStrategy({ git, canonical })
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
|
||||
const sourceDirectory = yield* canonical(input)
|
||||
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
|
||||
return sourceDirectory
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return strategy
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
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* selected.create({
|
||||
directory: copyDirectory,
|
||||
sourceDirectory,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
behavior: "replace",
|
||||
}),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
|
||||
const copyDirectory = yield* canonical(input.directory)
|
||||
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
|
||||
yield* strategy.remove({
|
||||
directory: copyDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* directories.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "copy" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const result = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.forEach(discovered, (item) =>
|
||||
directories.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
behavior: "replace",
|
||||
},
|
||||
tx,
|
||||
),
|
||||
),
|
||||
removed: Effect.forEach(removed, (directory) =>
|
||||
directories.remove({ projectID: input.projectID, directory }, tx),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const changes = {
|
||||
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
|
||||
removed: removed.filter((_, index) => result.removed[index]),
|
||||
}
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "project-copy-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
export * as ProjectDirectories from "./directories.js"
|
||||
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { ProjectSchema } from "./schema.js"
|
||||
import { ProjectDirectoryTable } from "./sql.js"
|
||||
import type { EffectDrizzleSqlite } from "../database/drizzle.js"
|
||||
import type { Project } from "../project.js"
|
||||
|
||||
export type Directory = Project.Directory
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
strategy: Schema.optional(Schema.String),
|
||||
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
|
||||
})
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export const ListInput = ProjectSchema.DirectoriesInput
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const ListOutput = ProjectSchema.Directories
|
||||
export type ListOutput = typeof ListOutput.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
|
||||
readonly get: (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Directory | undefined>
|
||||
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
|
||||
const insert = (tx ?? db)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
const query =
|
||||
input.behavior === "replace"
|
||||
? insert.onConflictDoUpdate({
|
||||
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
|
||||
: isNotNull(ProjectDirectoryTable.strategy),
|
||||
})
|
||||
: insert.onConflictDoNothing()
|
||||
return (
|
||||
(yield* query.returning({ directory: ProjectDirectoryTable.directory }).get().pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.delete(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.returning({ directory: ProjectDirectoryTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
})
|
||||
|
||||
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) {
|
||||
const row = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
remove,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
@@ -10,9 +10,18 @@ export type ID = typeof ID.Type
|
||||
export const Current = Project.Current
|
||||
export type Current = typeof Current.Type
|
||||
|
||||
export const Directory = Project.Directory
|
||||
export type Directory = typeof Directory.Type
|
||||
|
||||
export const Info = Project.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const DirectoriesInput = Project.DirectoriesInput
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = Project.Directories
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
|
||||
@@ -23,7 +23,6 @@ export const ProjectTable = sqliteTable("project", {
|
||||
commands: text({ mode: "json" }).$type<{ start?: string }>(),
|
||||
})
|
||||
|
||||
/** @deprecated Use WorktreeTable from worktree/sql instead. */
|
||||
export const ProjectDirectoryTable = sqliteTable(
|
||||
"project_directory",
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
@@ -442,7 +442,7 @@ const layer = Layer.effectDiscard(
|
||||
// Sessions whose ownership came from the directory's previous resolution
|
||||
// follow its new identity. Location, transcript, instructions, and recency
|
||||
// are untouched: the session did not move, its directory got identified.
|
||||
yield* bus.project(Worktree.Event.Resolved, (event) =>
|
||||
yield* bus.project(Event.Resolved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
|
||||
if (stale.length === 0) return
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
import { WorktreeGit } from "./worktree/git.js"
|
||||
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
|
||||
export const StrategyID = Worktree.StrategyID
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Worktree.CreateInput
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Worktree.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
}).annotate({ identifier: "Worktree.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "Worktree.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.Type
|
||||
|
||||
export const Info = Worktree.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const ListInput = Worktree.ListInput
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const List = Worktree.List
|
||||
export type List = typeof List.Type
|
||||
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "worktree"]),
|
||||
}).annotate({ identifier: "Worktree.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"Worktree.SourceDirectoryNotFoundError",
|
||||
{ projectID: ProjectSchema.ID },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
|
||||
"Worktree.DestinationExistsError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||
"Worktree.InvalidDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||
"Worktree.StrategyUnavailableError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
|
||||
"Worktree.DuplicateStrategyError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export const Event = Worktree.Event
|
||||
|
||||
interface StoredInput {
|
||||
readonly projectID: ProjectSchema.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategy?: string
|
||||
}
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const worktrees = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
|
||||
const result = yield* worktrees.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("worktree refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const ops = {
|
||||
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, projectID))
|
||||
.orderBy(desc(WorktreeTable.time_created), asc(WorktreeTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
}),
|
||||
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
|
||||
const row = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
}),
|
||||
create: Effect.fnUntraced(function* (input: StoredInput, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
.onConflictDoUpdate({
|
||||
target: [WorktreeTable.project_id, WorktreeTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
|
||||
: isNotNull(WorktreeTable.strategy),
|
||||
})
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
remove: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
const registry = new Map<StrategyID, Strategy>()
|
||||
|
||||
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
|
||||
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
|
||||
registry.set(strategy.id, strategy)
|
||||
})
|
||||
|
||||
// Register default strategies
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
yield* register(gitStrategy).pipe(Effect.orDie)
|
||||
|
||||
const strategies = () => Array.from(registry.values())
|
||||
|
||||
const source = Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
|
||||
const checked = yield* Effect.forEach(
|
||||
yield* ops.list(projectID),
|
||||
(item) =>
|
||||
canonical(fs, item.directory).pipe(
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const found = checked.find((directory) => directory !== undefined)
|
||||
if (!found) return yield* new SourceDirectoryNotFoundError({ projectID })
|
||||
return found
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
const found = registry.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
while (yield* fs.existsSafe(worktreeDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
|
||||
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* selected.create({
|
||||
directory: worktreeDirectory,
|
||||
sourceDirectory,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* ops.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
}),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
const worktreeDirectory = yield* canonical(fs, input.directory)
|
||||
const stored = yield* ops.find(input.projectID, worktreeDirectory)
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
|
||||
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
|
||||
directory: worktreeDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* ops.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(strategies(), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "worktree" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const result = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.forEach(discovered, (item) =>
|
||||
ops.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
},
|
||||
tx,
|
||||
),
|
||||
),
|
||||
removed: Effect.forEach(removed, (directory) => ops.remove(input.projectID, directory, tx)),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const changes = {
|
||||
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
|
||||
removed: removed.filter((_, index) => result.removed[index]),
|
||||
}
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register,
|
||||
list: ops.list,
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
export * as WorktreeDirectory from "./directory.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
|
||||
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
|
||||
"Worktree.DirectoryUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export const canonical = Effect.fnUntraced(function* (fs: FSUtil.Interface, input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(yield* fs.resolve(input))
|
||||
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
|
||||
return resolved
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
export * as WorktreeGit from "./git.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./directory.js"
|
||||
import type { ListEntry, Strategy } from "../worktree.js"
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
return {
|
||||
id: Worktree.StrategyID.make("git"),
|
||||
create: Effect.fn("Worktree.Git.create")(function* (input) {
|
||||
const repository = yield* git.repo.discover(input.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory })
|
||||
yield* git.worktree.create({ repository, directory: input.directory })
|
||||
return { directory: yield* canonical(fs, input.directory) }
|
||||
}),
|
||||
remove: Effect.fn("Worktree.Git.remove")(function* (input) {
|
||||
const repository = yield* git.repo.discover(input.directory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.directory })
|
||||
yield* git.worktree.remove({ repository, directory: input.directory, force: input.force })
|
||||
}),
|
||||
list: Effect.fn("Worktree.Git.list")(function* (directory) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory })
|
||||
const entries = yield* git.worktree.list(repository)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
canonical(fs, entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { absoluteColumn } from "../database/path.js"
|
||||
import { ProjectSchema } from "../project/schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
export const WorktreeTable = sqliteTable(
|
||||
"worktree",
|
||||
{
|
||||
project_id: text()
|
||||
.$type<ProjectSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
directory: absoluteColumn().notNull(),
|
||||
strategy: text(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
|
||||
)
|
||||
@@ -68,26 +68,6 @@ describe("Agent", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists the selected default agent first", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* agent.transform((editor) => {
|
||||
editor.update(Agent.ID.make("build"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("reviewer"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("explore"), (info) => {
|
||||
info.mode = "subagent"
|
||||
})
|
||||
editor.default(Agent.ID.make("reviewer"))
|
||||
})
|
||||
|
||||
expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds state when a transform is replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -128,31 +127,6 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("copies project directories into worktrees without removing the old table", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE project_directory (project_id text NOT NULL, directory text NOT NULL, type text, strategy text, time_created integer NOT NULL, PRIMARY KEY (project_id, directory))`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project_directory (project_id, directory, type, strategy, time_created) VALUES ('project', '/root', 'main', NULL, 1), ('project', '/legacy', 'git_worktree', NULL, 2), ('project', '/strategy', NULL, 'git_worktree', 3), ('project', '/custom', NULL, 'acme/snapshot', 4)`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [worktreeMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT directory, strategy FROM worktree ORDER BY directory`)).toEqual([
|
||||
{ directory: "/custom", strategy: "acme/snapshot" },
|
||||
{ directory: "/legacy", strategy: "git" },
|
||||
{ directory: "/root", strategy: null },
|
||||
{ directory: "/strategy", strategy: "git" },
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT count(*) AS count FROM project_directory`)).toEqual({ count: 4 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const source = path.join(tmp.path, "auth.json")
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("node build", () => {
|
||||
acquisitions++
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
||||
@@ -57,70 +56,4 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
let scans = 0
|
||||
const initial = Effect.runSync(Deferred.make<void>())
|
||||
const started = Effect.runSync(Deferred.make<void>())
|
||||
const release = Effect.runSync(Deferred.make<void>())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans === 1) yield* Deferred.succeed(initial, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Deferred.await(initial)
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
expect(scans).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* search.find({ query: "old", type: "file" })
|
||||
yield* Deferred.await(started)
|
||||
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
expect(scans).toBe(2)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const refreshed = yield* Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
return yield* search.find({ query: "new", type: "file" })
|
||||
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
|
||||
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
|
||||
expect(scans).toBe(2)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,50 +111,6 @@ describe("InstructionDiscovery", () => {
|
||||
).toBe(false)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
|
||||
it.effect("renders granular instruction updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.add(file("/global/AGENTS.md", "global"))
|
||||
draft.add(
|
||||
file("/repo/AGENTS.md", ["old", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")),
|
||||
)
|
||||
})
|
||||
const initial = yield* readInitial(yield* discovery.load())
|
||||
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.update("/repo/AGENTS.md", (current) => {
|
||||
current.content = ["new", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")
|
||||
})
|
||||
})
|
||||
const modified = (yield* readUpdate(yield* discovery.load(), initial)).text
|
||||
expect(modified).toContain("The instructions from /repo/AGENTS.md changed. Here's the diff:")
|
||||
expect(modified).toContain("-old\n+new")
|
||||
expect(modified).not.toContain("global")
|
||||
|
||||
const rewritten = state({
|
||||
"core/instructions": [{ path: "/repo/AGENTS.md", content: "old one\nold two\nold three\nold four" }],
|
||||
})
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.remove("/global/AGENTS.md")
|
||||
draft.update("/repo/AGENTS.md", (current) => {
|
||||
current.content = "new"
|
||||
})
|
||||
})
|
||||
expect((yield* readUpdate(yield* discovery.load(), rewritten)).text).toBe(
|
||||
"The instructions changed:\nInstructions from: /repo/AGENTS.md\nnew",
|
||||
)
|
||||
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.add(file("/repo/packages/AGENTS.md", "package"))
|
||||
})
|
||||
const structural = (yield* readUpdate(yield* discovery.load(), initial)).text
|
||||
expect(structural).toContain("The instructions from /global/AGENTS.md no longer apply.")
|
||||
expect(structural).toContain("New instructions apply from:\nInstructions from: /repo/packages/AGENTS.md\npackage")
|
||||
expect(structural).not.toContain("Instructions from: /global/AGENTS.md\nglobal")
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
@@ -212,15 +168,20 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
yield* emitAndWait({ type: "update", path: packageFile })
|
||||
const changed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
||||
expect(changed).toContain(`The instructions changed:\nInstructions from: ${packageFile}\nchanged`)
|
||||
expect(changed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
|
||||
`Instructions from: ${packageFile}\nchanged`,
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
yield* emitAndWait({ type: "delete", path: packageFile })
|
||||
const removed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
||||
expect(removed).toContain(`The instructions from ${packageFile} no longer apply.`)
|
||||
expect(removed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
[
|
||||
"These instructions replace all previously loaded ambient instructions.",
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
`Instructions from: ${sharedFile}\nshared`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(globalFile))
|
||||
yield* emitAndWait({ type: "delete", path: globalFile })
|
||||
|
||||
@@ -6,5 +6,6 @@ export const globalProjectLayer = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -11,19 +11,21 @@ import { Git } from "@opencode-ai/core/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, Bus.node, ProjectDirectories.node])),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
const gitWorktree = Worktree.StrategyID.make("git")
|
||||
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
@@ -42,7 +44,7 @@ function setup() {
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const projectID = Project.ID.make("worktree-project")
|
||||
const projectID = Project.ID.make("copy-project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
@@ -50,7 +52,7 @@ function setup() {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: projectID, directory: sourceDirectory })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -61,9 +63,9 @@ function setup() {
|
||||
function stored(projectID: Project.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, projectID))
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
@@ -72,151 +74,92 @@ function stored(projectID: Project.ID) {
|
||||
)
|
||||
}
|
||||
|
||||
describe("Worktree", () => {
|
||||
describe("ProjectCopy", () => {
|
||||
it.effect("accepts arbitrary non-empty strategy ids", () =>
|
||||
Effect.sync(() => {
|
||||
expect(String(Worktree.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
|
||||
expect(() => Worktree.StrategyID.make(" acme/snapshot ")).toThrow()
|
||||
expect(() => Worktree.StrategyID.make(" ")).toThrow()
|
||||
expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
|
||||
expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
|
||||
expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports unavailable strategy ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const unavailable = Worktree.StrategyID.make("acme/missing")
|
||||
const error = yield* worktree
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
|
||||
const error = yield* copy
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: unavailable,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-strategy`),
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires a tracked project worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* input.db
|
||||
.delete(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: abs(`${input.root.path}-missing-source`),
|
||||
name: "worktree",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
|
||||
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.projectID).toBe(input.projectID)
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates and removes a git worktree directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const bus = yield* Bus.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
|
||||
const target = abs(path.join(parent, "worktree"))
|
||||
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(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const fiber = yield* bus
|
||||
.subscribe(Worktree.Event.Updated)
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* worktree.create({
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
expect(created.directory).toBe(target)
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: created.directory, strategy: "git" },
|
||||
{ directory: created.directory, strategy: "git_worktree" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates from another managed worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const sourceParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-source"))
|
||||
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.all([
|
||||
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
const source = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: sourceParent,
|
||||
name: "source",
|
||||
})
|
||||
yield* input.db
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, input.projectID), isNull(WorktreeTable.strategy)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: targetParent,
|
||||
name: "target",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires force to remove a dirty git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
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) + "-worktree-dirty"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const created = yield* worktree.create({
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
|
||||
const error = yield* worktree
|
||||
const error = yield* copy
|
||||
.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
@@ -225,113 +168,115 @@ describe("Worktree", () => {
|
||||
expect(error.operation).toBe("remove")
|
||||
expect(error.forceRequired).toBe(true)
|
||||
}
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves worktrees whose stored strategy is unavailable", () =>
|
||||
it.live("preserves copies whose stored strategy is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const unavailable = abs(`${input.root.path}-worktree-unavailable`)
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const unavailable = abs(`${input.root.path}-copy-unavailable`)
|
||||
yield* Effect.promise(() => fs.mkdir(unavailable))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
|
||||
yield* input.db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const error = yield* worktree
|
||||
const error = yield* copy
|
||||
.remove({ projectID: input.projectID, directory: unavailable, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds a numeric suffix when a worktree directory already exists", () =>
|
||||
it.live("adds a numeric suffix when a copy directory already exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
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) + "-worktree-suffix"))
|
||||
const target = abs(path.join(parent, "worktree-3"))
|
||||
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, "worktree"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
|
||||
|
||||
const created = yield* worktree.create({
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(target)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
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* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fails after ten worktree directory conflicts", () =>
|
||||
it.live("fails after ten copy directory conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
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) + "-worktree-conflicts"))
|
||||
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 ? "worktree" : `worktree-${index + 1}`), { recursive: true }),
|
||||
fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const error = yield* worktree
|
||||
const error = yield* copy
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.DestinationExistsError)
|
||||
if (error instanceof Worktree.DestinationExistsError)
|
||||
expect(error.directory).toBe(abs(path.join(parent, "worktree-10")))
|
||||
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
|
||||
if (error instanceof 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()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
|
||||
const event = yield* bus.subscribe(ProjectCopy.Event.Updated).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
@@ -344,36 +289,36 @@ describe("Worktree", () => {
|
||||
it.live("refresh discovers and prunes an externally managed git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const bus = yield* Bus.Service
|
||||
const target = abs(`${input.root.path}-worktree-external`)
|
||||
const target = abs(`${input.root.path}-copy-external`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
yield* input.db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: target })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const fiber = yield* bus
|
||||
.subscribe(Worktree.Event.Updated)
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git" },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
)
|
||||
@@ -381,9 +326,9 @@ describe("Worktree", () => {
|
||||
it.live("refresh ignores stale git worktree registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const stale = abs(`${input.root.path}-worktree-stale`)
|
||||
const target = abs(`${input.root.path}-worktree-after-stale`)
|
||||
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),
|
||||
)
|
||||
@@ -391,13 +336,13 @@ describe("Worktree", () => {
|
||||
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* worktree.refresh({ projectID: input.projectID })
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git" },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
@@ -407,9 +352,9 @@ describe("Worktree", () => {
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
@@ -417,9 +362,9 @@ describe("Worktree", () => {
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
updated: [],
|
||||
removed: [],
|
||||
})
|
||||
@@ -431,13 +376,13 @@ describe("Worktree", () => {
|
||||
const input = yield* setup()
|
||||
const missing = abs(`${input.root.path}-missing-checkout`)
|
||||
yield* input.db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: missing })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
|
||||
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
|
||||
}),
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node])))
|
||||
|
||||
const projectID = Project.ID.make("project-directories")
|
||||
const directory = AbsolutePath.make("/tmp/project-directories")
|
||||
|
||||
function setup() {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProjectDirectories", () => {
|
||||
it.effect("decodes directory schemas", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates once and ignores conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
|
||||
expect(yield* service.create({ projectID, directory })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an empty list for missing projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ProjectDirectories.Service
|
||||
|
||||
expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the strategy when requested", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
yield* service.create({ projectID, directory, strategy: "old/strategy" })
|
||||
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(false)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2,13 +2,11 @@ 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, Schema } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -367,15 +365,8 @@ describe("Project.resolve", () => {
|
||||
time: { created: 1, initialized: 2 },
|
||||
})
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, id))
|
||||
.all()
|
||||
.pipe(Effect.orDie))
|
||||
.map((item) => ({ directory: item.directory, strategy: item.strategy ?? undefined }))
|
||||
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
).toEqual([{ directory: yield* real(tmp.path), strategy: undefined }, { directory: yield* real(worktree), strategy: "git" }])
|
||||
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("Session.create", () => {
|
||||
.where(eq(EventTable.aggregate_id, project.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(announced.map((event) => event.type)).toEqual(["worktree.resolved.1"])
|
||||
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -92,7 +92,7 @@ describe("Session.move", () => {
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
// The former directory becomes a project after the session left it.
|
||||
yield* bus.publish(Worktree.Event.Resolved, {
|
||||
yield* bus.publish(Event.Resolved, {
|
||||
projectID: Project.ID.make("adopting"),
|
||||
directory: previous,
|
||||
previous: Project.ID.global,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
@@ -39,7 +39,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
coreModel,
|
||||
corePermission,
|
||||
corePermissionV1,
|
||||
coreWorktree,
|
||||
coreProjectCopy,
|
||||
corePty,
|
||||
coreProject,
|
||||
coreProvider,
|
||||
@@ -60,7 +60,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
import("@opencode-ai/core/model"),
|
||||
import("@opencode-ai/core/permission"),
|
||||
import("@opencode-ai/core/v1/permission"),
|
||||
import("@opencode-ai/core/worktree"),
|
||||
import("@opencode-ai/core/project/copy"),
|
||||
import("@opencode-ai/core/pty"),
|
||||
import("@opencode-ai/core/project/schema"),
|
||||
import("@opencode-ai/core/provider"),
|
||||
@@ -112,16 +112,14 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[corePermission.Rule, Permission.Rule],
|
||||
[corePermission.Ruleset, Permission.Ruleset],
|
||||
[corePermissionV1.Event, PermissionV1.Event],
|
||||
[coreWorktree.CreateInput, Worktree.CreateInput],
|
||||
[coreWorktree.RemoveInput, Worktree.RemoveInput],
|
||||
[coreWorktree.Info, Worktree.Info],
|
||||
[coreWorktree.ListInput, Worktree.ListInput],
|
||||
[coreWorktree.List, Worktree.List],
|
||||
[coreWorktree.Event, Worktree.Event],
|
||||
[coreProjectCopy.Event, ProjectDirectories.Event],
|
||||
[corePty.Info, Pty.Info],
|
||||
[corePty.Event, Pty.Event],
|
||||
[coreProject.ID, Project.ID],
|
||||
[coreProject.Current, Project.Current],
|
||||
[coreProject.Directory, Project.Directory],
|
||||
[coreProject.DirectoriesInput, Project.DirectoriesInput],
|
||||
[coreProject.Directories, Project.Directories],
|
||||
[coreReference.LocalSource, Reference.LocalSource],
|
||||
[coreReference.GitSource, Reference.GitSource],
|
||||
[coreReference.Source, Reference.Source],
|
||||
|
||||
+1366
-4490
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ import { WebSearchGroup } from "./groups/websearch.js"
|
||||
import { McpGroup } from "./groups/mcp.js"
|
||||
import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
import { WorktreeGroup } from "./groups/worktree.js"
|
||||
import { ProjectCopyGroup } from "./groups/project-copy.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
import { MigrationGroup } from "./groups/migration.js"
|
||||
import { ConfigGroup } from "./groups/config.js"
|
||||
@@ -52,6 +52,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
|
||||
|
||||
@@ -88,7 +89,6 @@ type ApiGroups<
|
||||
| typeof ServerGroup
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
| typeof WorktreeGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -172,7 +172,7 @@ const makeApiFromGroup = <
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup)
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(MigrationGroup)
|
||||
|
||||
@@ -60,7 +60,7 @@ export const groupNames = {
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.worktree": "worktree",
|
||||
"server.projectCopy": "projectCopy",
|
||||
"server.vcs": "vcs",
|
||||
"server.config": "config",
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const root = "/api/experimental/project/:projectID/copy"
|
||||
|
||||
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
|
||||
{
|
||||
name: Schema.Literal("ProjectCopyError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
forceRequired: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"]))
|
||||
const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"]))
|
||||
|
||||
export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
|
||||
.add(
|
||||
HttpApiEndpoint.post("projectCopy.create", root, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
payload: CreatePayload,
|
||||
success: ProjectCopy.Copy,
|
||||
error: ProjectCopyError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("projectCopy.remove", root, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
payload: RemovePayload,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: ProjectCopyError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: ProjectCopyError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
|
||||
@@ -31,9 +31,24 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("project.directories", `${root}/:projectID/directories`, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
success: Project.Directories,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.project.directories",
|
||||
summary: "List project directories",
|
||||
description: "List known local absolute directories for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "project",
|
||||
description: "Project routes.",
|
||||
description: "Location-scoped project routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
const root = "/api/experimental/project/:projectID/worktree"
|
||||
|
||||
export class WorktreeError extends Schema.ErrorClass<WorktreeError>("WorktreeError")(
|
||||
{
|
||||
name: Schema.Literal("WorktreeError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
forceRequired: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"]))
|
||||
const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"]))
|
||||
|
||||
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktree.list", root, {
|
||||
params: { projectID: Project.ID },
|
||||
success: Worktree.List,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.list",
|
||||
summary: "List worktrees",
|
||||
description: "List known local worktrees for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktree.create", root, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: CreatePayload,
|
||||
success: Worktree.Info,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.create",
|
||||
summary: "Create worktree",
|
||||
description: "Create a worktree for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktree.remove", root, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: RemovePayload,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.remove",
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a managed worktree from a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
|
||||
params: { projectID: Project.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.refresh",
|
||||
summary: "Refresh worktrees",
|
||||
description: "Reconcile stored worktrees with the project repositories.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." }))
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as DurableEventManifest from "./durable-event-manifest.js"
|
||||
|
||||
import { Event } from "./event.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { ProjectDirectories } from "./project-directories.js"
|
||||
import { SessionEvent } from "./session-event.js"
|
||||
|
||||
export const SessionDurable = {
|
||||
@@ -9,4 +9,4 @@ export const SessionDurable = {
|
||||
schema: SessionEvent.Durable,
|
||||
} as const
|
||||
|
||||
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, Worktree.Event.Resolved])
|
||||
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, ProjectDirectories.Event.Resolved])
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ModelsDev } from "./models-dev.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { ProjectDirectories } from "./project-directories.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Reference } from "./reference.js"
|
||||
@@ -50,7 +50,7 @@ const featureDefinitions = Event.inventory(
|
||||
...Reference.Event.Definitions,
|
||||
...Permission.Event.Definitions,
|
||||
...Plugin.Event.Definitions,
|
||||
...Worktree.Event.Definitions,
|
||||
...ProjectDirectories.Event.Definitions,
|
||||
...Command.Event.Definitions,
|
||||
...Config.Event.Definitions,
|
||||
...Skill.Event.Definitions,
|
||||
|
||||
@@ -16,7 +16,7 @@ export { Money } from "./money.js"
|
||||
export { Permission } from "./permission.js"
|
||||
export { PermissionSaved } from "./permission-saved.js"
|
||||
export { Project } from "./project.js"
|
||||
export { Worktree } from "./worktree.js"
|
||||
export { ProjectCopy } from "./project-copy.js"
|
||||
export { Provider } from "./provider.js"
|
||||
export { Reference } from "./reference.js"
|
||||
export { WebSearch } from "./websearch.js"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export * as ProjectCopy from "./project-copy.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { ProjectID } from "./project-id.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
strategy: StrategyID,
|
||||
sourceDirectory: AbsolutePath,
|
||||
directory: AbsolutePath,
|
||||
name: optional(Schema.String),
|
||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
directory: AbsolutePath,
|
||||
force: Schema.Boolean,
|
||||
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
|
||||
export interface RemoveInput extends Schema.Schema.Type<typeof RemoveInput> {}
|
||||
|
||||
export const Copy = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.Copy" })
|
||||
export interface Copy extends Schema.Schema.Type<typeof Copy> {}
|
||||
@@ -0,0 +1,53 @@
|
||||
export * as ProjectDirectories from "./project-directories.js"
|
||||
|
||||
import { durable, ephemeral, inventory } from "./event.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Project } from "./project.js"
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "project.directories.updated",
|
||||
schema: { projectID: Project.ID },
|
||||
})
|
||||
|
||||
/**
|
||||
* A directory's resolution changed: it now resolves to `projectID` where it
|
||||
* previously resolved to `previous` (`global` when the directory had no
|
||||
* stable identity yet, e.g. before `git init`). Sessions whose ownership
|
||||
* came from the previous resolution follow the new identity by projection.
|
||||
*/
|
||||
const Resolved = durable({
|
||||
type: "project.directory.resolved",
|
||||
durable: { aggregate: "projectID", version: 1 },
|
||||
schema: {
|
||||
projectID: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
previous: Project.ID,
|
||||
},
|
||||
})
|
||||
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
|
||||
|
||||
/**
|
||||
* Client-side mirror of the server's `project.directory.resolved` session fold.
|
||||
* Returns the ownership update for a cached session, or undefined when the
|
||||
* session does not follow the resolution. Plain strings: callers hold
|
||||
* generated client types, and the server projection remains authoritative.
|
||||
*/
|
||||
export function adopt(
|
||||
session: { readonly projectID: string; readonly directory: string },
|
||||
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
|
||||
) {
|
||||
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
|
||||
if (session.projectID === event.projectID) return
|
||||
const inside =
|
||||
session.directory === event.directory ||
|
||||
session.directory.startsWith(event.directory + "/") ||
|
||||
session.directory.startsWith(event.directory + "\\")
|
||||
if (!inside) return
|
||||
return {
|
||||
projectID: event.projectID,
|
||||
subpath:
|
||||
session.directory === event.directory
|
||||
? undefined
|
||||
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,17 @@ export const Current = Schema.Struct({
|
||||
canonical: AbsolutePath,
|
||||
}).annotate({ identifier: "Project.Current" })
|
||||
export interface Current extends Schema.Schema.Type<typeof Current> {}
|
||||
export const Directory = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
strategy: optional(Schema.String),
|
||||
}).annotate({ identifier: "Project.Directory" })
|
||||
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
|
||||
export const DirectoriesInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
}).annotate({ identifier: "Project.DirectoriesInput" })
|
||||
export interface DirectoriesInput extends Schema.Schema.Type<typeof DirectoriesInput> {}
|
||||
export const Directories = Schema.Array(Directory).annotate({ identifier: "Project.Directories" })
|
||||
export type Directories = typeof Directories.Type
|
||||
export const Icon = Schema.Struct({
|
||||
url: optional(Schema.String),
|
||||
override: optional(Schema.String),
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { durable, ephemeral, inventory } from "./event.js"
|
||||
import { ProjectID } from "./project-id.js"
|
||||
import { AbsolutePath, optional } from "./schema.js"
|
||||
import { Project } from "./project.js"
|
||||
|
||||
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("Worktree.StrategyID"))
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
strategy: StrategyID,
|
||||
directory: AbsolutePath,
|
||||
name: optional(Schema.String),
|
||||
}).annotate({ identifier: "Worktree.CreateInput" })
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
directory: AbsolutePath,
|
||||
force: Schema.Boolean,
|
||||
}).annotate({ identifier: "Worktree.RemoveInput" })
|
||||
export interface RemoveInput extends Schema.Schema.Type<typeof RemoveInput> {}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "Worktree.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Directory = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
strategy: optional(Schema.String),
|
||||
}).annotate({ identifier: "Worktree.Directory" })
|
||||
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
}).annotate({ identifier: "Worktree.ListInput" })
|
||||
export interface ListInput extends Schema.Schema.Type<typeof ListInput> {}
|
||||
|
||||
export const List = Schema.Array(Directory).annotate({ identifier: "Worktree.List" })
|
||||
export type List = typeof List.Type
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "worktree.updated",
|
||||
schema: { projectID: Project.ID },
|
||||
})
|
||||
|
||||
const Resolved = durable({
|
||||
type: "worktree.resolved",
|
||||
durable: { aggregate: "projectID", version: 1 },
|
||||
schema: {
|
||||
projectID: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
previous: Project.ID,
|
||||
},
|
||||
})
|
||||
|
||||
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
|
||||
|
||||
export function adopt(
|
||||
session: { readonly projectID: string; readonly directory: string },
|
||||
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
|
||||
) {
|
||||
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
|
||||
if (session.projectID === event.projectID) return
|
||||
const inside =
|
||||
session.directory === event.directory ||
|
||||
session.directory.startsWith(event.directory + "/") ||
|
||||
session.directory.startsWith(event.directory + "\\")
|
||||
if (!inside) return
|
||||
return {
|
||||
projectID: event.projectID,
|
||||
subpath:
|
||||
session.directory === event.directory
|
||||
? undefined
|
||||
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import { Money } from "../src/money.js"
|
||||
import { Skill } from "../src/skill.js"
|
||||
import { Shell } from "../src/shell.js"
|
||||
import { Vcs } from "../src/vcs.js"
|
||||
import { Worktree } from "../src/worktree.js"
|
||||
import { PersistedRevert } from "../src/session-revert.js"
|
||||
import { AbsolutePath, optional } from "../src/schema.js"
|
||||
|
||||
@@ -158,9 +157,9 @@ describe("contract hygiene", () => {
|
||||
Model.Cost,
|
||||
Model.Variant,
|
||||
Project.Current,
|
||||
Worktree.Directory,
|
||||
Worktree.ListInput,
|
||||
Worktree.List,
|
||||
Project.Directory,
|
||||
Project.DirectoriesInput,
|
||||
Project.Directories,
|
||||
Project.Icon,
|
||||
Project.Commands,
|
||||
Project.Time,
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("public event manifest", () => {
|
||||
"session.revert.staged.1",
|
||||
"session.revert.cleared.1",
|
||||
"session.revert.committed.1",
|
||||
"worktree.resolved.1",
|
||||
"project.directory.resolved.1",
|
||||
].toSorted(),
|
||||
)
|
||||
expect(SessionEvent.DurableDefinitions).toEqual([
|
||||
|
||||
@@ -15,7 +15,7 @@ export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { ClientApi, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client"
|
||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||
@@ -24,7 +23,6 @@ const CoreAgent = await import("@opencode-ai/core/agent")
|
||||
const CoreModel = await import("@opencode-ai/core/model")
|
||||
const CoreProject = await import("@opencode-ai/core/project")
|
||||
const CoreSession = await import("@opencode-ai/core/session")
|
||||
const CoreWorktree = await import("@opencode-ai/core/worktree")
|
||||
|
||||
test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Agent).toBe(Agent)
|
||||
@@ -33,7 +31,6 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Model).toBe(Model)
|
||||
expect(SDK.WebSearch).toBe(WebSearch)
|
||||
expect(SDK.Session).toBe(Session)
|
||||
expect(SDK.Worktree).toBe(Worktree)
|
||||
expect(SDK.Workspace).toBe(Workspace)
|
||||
expect(Object.keys(SDK).sort()).toEqual([
|
||||
"AbsolutePath",
|
||||
@@ -51,6 +48,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"Permission",
|
||||
"PermissionSaved",
|
||||
"Project",
|
||||
"ProjectCopy",
|
||||
"Prompt",
|
||||
"PromptInput",
|
||||
"Provider",
|
||||
@@ -65,7 +63,6 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"Tool",
|
||||
"WebSearch",
|
||||
"Workspace",
|
||||
"Worktree",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -75,9 +72,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
||||
expect(CoreModel.Ref).toBe(Model.Ref)
|
||||
expect(CoreSession.Info).toBe(Session.Info)
|
||||
expect(CoreProject.Current).toBe(Project.Current)
|
||||
expect(CoreWorktree.DirectoryUnavailableError).toBeDefined()
|
||||
expect(CoreWorktree.List).toBe(Worktree.List)
|
||||
expect(CoreWorktree.Info).toBe(Worktree.Info)
|
||||
expect(CoreProject.Directory).toBe(Project.Directory)
|
||||
expect(CoreProject.Directories).toBe(Project.Directories)
|
||||
expect(CoreSessionInbox.Item).toBe(SessionInbox.Item)
|
||||
expect(CoreSessionInbox.User).toBe(SessionInbox.User)
|
||||
expect(CoreSessionInbox.Synthetic).toBe(SessionInbox.Synthetic)
|
||||
|
||||
@@ -25,7 +25,7 @@ import { WebSearchHandler } from "./handlers/websearch"
|
||||
import { McpHandler } from "./handlers/mcp"
|
||||
import { CredentialHandler } from "./handlers/credential"
|
||||
import { ProjectHandler } from "./handlers/project"
|
||||
import { WorktreeHandler } from "./handlers/worktree"
|
||||
import { ProjectCopyHandler } from "./handlers/project-copy"
|
||||
import { VcsHandler } from "./handlers/vcs"
|
||||
import { EventFeed } from "./event-feed"
|
||||
import { MigrationHandler } from "./handlers/migration"
|
||||
@@ -59,7 +59,7 @@ export const handlers = Layer.mergeAll(
|
||||
ShellHandler,
|
||||
QuestionHandler,
|
||||
ReferenceHandler,
|
||||
WorktreeHandler,
|
||||
ProjectCopyHandler,
|
||||
VcsHandler,
|
||||
ConfigHandler,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy"
|
||||
|
||||
export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) =>
|
||||
Effect.succeed(
|
||||
handlers
|
||||
.handle("projectCopy.create", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const copies = yield* ProjectCopy.Service
|
||||
const location = yield* Location.Service
|
||||
return yield* badRequest(
|
||||
copies.create({
|
||||
...ctx.payload,
|
||||
projectID: ctx.params.projectID,
|
||||
sourceDirectory: location.project.directory,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle("projectCopy.remove", (ctx) =>
|
||||
ProjectCopy.Service.use((copies) =>
|
||||
badRequest(copies.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.handle("projectCopy.refresh", (ctx) =>
|
||||
ProjectCopy.Service.use((copies) =>
|
||||
badRequest(copies.refresh({ projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ProjectCopyError({
|
||||
name: "ProjectCopyError",
|
||||
data: {
|
||||
message: message(error),
|
||||
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function message(error: ProjectCopy.Error) {
|
||||
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
|
||||
return `Project copy source not found: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.DestinationExistsError)
|
||||
return `Project copy destination already exists: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.DirectoryUnavailableError)
|
||||
return `Project copy directory unavailable: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.InvalidDirectoryError) return `Invalid project copy directory: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.StrategyUnavailableError)
|
||||
return `Project copy strategy unavailable: ${error.strategy}`
|
||||
return error.message
|
||||
}
|
||||
@@ -15,5 +15,8 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
|
||||
canonical: location.project.canonical,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handle("project.directories", (ctx) =>
|
||||
Project.Service.use((project) => project.directories({ projectID: ctx.params.projectID })),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { WorktreeError } from "@opencode-ai/protocol/groups/worktree"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const WorktreeHandler = HttpApiBuilder.group(Api, "server.worktree", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const worktrees = yield* Worktree.Service
|
||||
|
||||
return handlers
|
||||
.handle("worktree.list", (ctx) => worktrees.list(ctx.params.projectID))
|
||||
.handle("worktree.create", (ctx) =>
|
||||
badRequest(worktrees.create({ ...ctx.payload, projectID: ctx.params.projectID })),
|
||||
)
|
||||
.handle("worktree.remove", (ctx) =>
|
||||
badRequest(worktrees.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
)
|
||||
.handle("worktree.refresh", (ctx) =>
|
||||
badRequest(worktrees.refresh({ projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new WorktreeError({
|
||||
name: "WorktreeError",
|
||||
data: {
|
||||
message: message(error),
|
||||
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function message(error: Worktree.Error) {
|
||||
if (error instanceof Worktree.SourceDirectoryNotFoundError)
|
||||
return `Worktree source not found for project: ${error.projectID}`
|
||||
if (error instanceof Worktree.DestinationExistsError) return `Worktree destination already exists: ${error.directory}`
|
||||
if (error instanceof Worktree.DirectoryUnavailableError) return `Worktree directory unavailable: ${error.directory}`
|
||||
if (error instanceof Worktree.InvalidDirectoryError) return `Invalid worktree directory: ${error.directory}`
|
||||
if (error instanceof Worktree.StrategyUnavailableError) return `Worktree strategy unavailable: ${error.strategy}`
|
||||
return error.message
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
@@ -53,7 +52,6 @@ const applicationServiceNodes = [
|
||||
httpClient,
|
||||
Job.node,
|
||||
Project.node,
|
||||
Worktree.node,
|
||||
Session.node,
|
||||
SessionTransfer.node,
|
||||
PluginRuntime.providerNode,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { $ } from "bun"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("lists, creates, and removes worktrees by project ID", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const destination = path.join(tmp.path, "worktrees")
|
||||
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
||||
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: path.join(tmp.path, "config") },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
|
||||
const location = new URL("/api/location", base)
|
||||
location.searchParams.set("location[directory]", project)
|
||||
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
|
||||
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
|
||||
throw new Error("Expected resolved project")
|
||||
const url = new URL(`/api/experimental/project/${resolved.project.id}/worktree`, base)
|
||||
|
||||
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
|
||||
expect(initial).toEqual([{ directory: project }])
|
||||
|
||||
const created = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
|
||||
}).then((response) => response.json()),
|
||||
)
|
||||
expect(created).toEqual({ directory: path.join(destination, "api") })
|
||||
|
||||
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
|
||||
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
|
||||
|
||||
const removed = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
|
||||
}),
|
||||
)
|
||||
expect(removed.status).toBe(204)
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -115,10 +115,9 @@ export function elements(renderer: CliRenderer): Element[] {
|
||||
}
|
||||
|
||||
export function state(harness: Harness) {
|
||||
const renderable = harness.renderer.currentFocusedRenderable?.num
|
||||
return {
|
||||
focused: {
|
||||
...(renderable === undefined ? {} : { renderable }),
|
||||
renderable: harness.renderer.currentFocusedRenderable?.num,
|
||||
editor: Boolean(harness.renderer.currentFocusedEditor),
|
||||
},
|
||||
elements: elements(harness.renderer),
|
||||
|
||||
@@ -14,18 +14,6 @@ test("matches literal screen text", () => {
|
||||
expect(matches(harness, "opencode")).toBe(false)
|
||||
})
|
||||
|
||||
test("omits an absent focused renderable from state", () => {
|
||||
const harness = {
|
||||
renderer: {
|
||||
root: { getChildren: () => [] },
|
||||
currentFocusedRenderable: undefined,
|
||||
currentFocusedEditor: undefined,
|
||||
},
|
||||
} as unknown as Harness
|
||||
|
||||
expect(state(harness)).toEqual({ focused: { editor: false }, elements: [] })
|
||||
})
|
||||
|
||||
test("normalizes named keys for OpenTUI", async () => {
|
||||
const pressed: Array<readonly [string, object | undefined]> = []
|
||||
const harness = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Experiment = {
|
||||
id: string
|
||||
id: "tab_drafts"
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
@@ -13,30 +12,36 @@ type Experiment = {
|
||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||
// experiment either graduates (delete the entry, make the behavior
|
||||
// unconditional) or dies (delete the entry and the branch it gated).
|
||||
export const experiments: Experiment[] = []
|
||||
export const experiments: Experiment[] = [
|
||||
{
|
||||
id: "tab_drafts",
|
||||
title: "Per-tab prompt drafts",
|
||||
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
const [selected, setSelected] = createSignal<Experiment>()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
||||
|
||||
const options = createMemo(() =>
|
||||
experiments.map((experiment) => ({
|
||||
experiments.map((experiment, index) => ({
|
||||
title: experiment.title,
|
||||
category: "Experiments",
|
||||
searchText: experiment.description,
|
||||
footer: enabled(experiment) ? "on" : "off",
|
||||
value: experiment,
|
||||
value: index,
|
||||
})),
|
||||
)
|
||||
|
||||
// All experiments are booleans, so either direction toggles.
|
||||
async function change(experiment = selected()) {
|
||||
async function change(index = selected()) {
|
||||
if (saving()) return
|
||||
const experiment = experiments[index]
|
||||
if (!experiment) return
|
||||
const next = !enabled(experiment)
|
||||
setSaving(true)
|
||||
@@ -53,33 +58,23 @@ export function DialogExperiments() {
|
||||
<DialogSelect
|
||||
title="Experiments"
|
||||
options={options()}
|
||||
renderFilter={experiments.length > 0}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(option.value)}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No experiments available</text>
|
||||
</box>
|
||||
}
|
||||
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
|
||||
bindings={
|
||||
experiments.length > 0
|
||||
? [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
footerHints={[{ title: "←/→", label: "change" }]}
|
||||
bindings={[
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ import { isRecord } from "../util/record"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { Spinner } from "./spinner"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { WorktreeListOutput } from "@opencode-ai/client"
|
||||
import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogWorktreeName } from "./dialog-worktree-name"
|
||||
import { DialogProjectCopyName } from "./dialog-project-copy-name"
|
||||
|
||||
export type MoveSessionSelection =
|
||||
| { type: "directory"; directory: string; subdirectory: boolean }
|
||||
| { type: "new"; name: string }
|
||||
type ProjectDirectory = WorktreeListOutput[number]
|
||||
type ProjectDirectory = ProjectDirectoriesOutput[number]
|
||||
|
||||
type DialogMoveSessionProps = {
|
||||
projectID: string
|
||||
@@ -78,8 +78,15 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
() => (props.initialRemoving ? undefined : props.projectID),
|
||||
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
|
||||
try {
|
||||
await client.api.worktree.refresh({ projectID })
|
||||
const directories = await client.api.worktree.list({ projectID })
|
||||
const requestLocation = { directory: location()?.directory || paths.cwd }
|
||||
await client.api.projectCopy.refresh({
|
||||
projectID,
|
||||
location: requestLocation,
|
||||
})
|
||||
const directories = await client.api.project.directories({
|
||||
projectID,
|
||||
location: requestLocation,
|
||||
})
|
||||
setLoadError(undefined)
|
||||
return directories
|
||||
} catch (error) {
|
||||
@@ -227,9 +234,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
setToDelete(undefined)
|
||||
setRemoving(selected.directory)
|
||||
setWorking(true)
|
||||
const error = await client.api.worktree
|
||||
const error = await client.api.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: location()?.directory || paths.cwd },
|
||||
directory: selected.directory,
|
||||
force: false,
|
||||
})
|
||||
@@ -245,17 +253,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
.status({ location: { directory: selected.directory } })
|
||||
.catch(() => undefined)
|
||||
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
|
||||
title: "Delete worktree?",
|
||||
message: "This worktree has file changes. Do you want to delete it anyway?",
|
||||
title: "Delete working copy?",
|
||||
message: "This working copy has file changes. Do you want to delete it anyway?",
|
||||
})
|
||||
if (choice !== "yes") {
|
||||
reopen()
|
||||
return
|
||||
}
|
||||
reopen(selected.directory)
|
||||
const forcedError = await client.api.worktree
|
||||
const forcedError = await client.api.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: location()?.directory || paths.cwd },
|
||||
directory: selected.directory,
|
||||
force: true,
|
||||
})
|
||||
@@ -266,7 +275,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (forcedError) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete worktree",
|
||||
title: "Failed to delete project copy",
|
||||
message: errorMessage(forcedError),
|
||||
})
|
||||
reopen()
|
||||
@@ -280,7 +289,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
}
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete worktree",
|
||||
title: "Failed to delete project copy",
|
||||
message: errorMessage(error),
|
||||
})
|
||||
return
|
||||
@@ -292,7 +301,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const name = await DialogWorktreeName.show(dialog)
|
||||
const name = await DialogProjectCopyName.show(dialog)
|
||||
if (name === null) return
|
||||
props.onSelect({ type: "new", name })
|
||||
}
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@ import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) {
|
||||
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -30,8 +30,8 @@ export function DialogWorktreeName(props: { onConfirm: (name: string) => void })
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "dialog.worktree.generate",
|
||||
title: "Generate worktree name",
|
||||
id: "dialog.project_copy.generate",
|
||||
title: "Generate project copy name",
|
||||
group: "Dialog",
|
||||
run: generate,
|
||||
},
|
||||
@@ -50,7 +50,7 @@ export function DialogWorktreeName(props: { onConfirm: (name: string) => void })
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Name worktree
|
||||
Name project copy
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
@@ -63,7 +63,7 @@ export function DialogWorktreeName(props: { onConfirm: (name: string) => void })
|
||||
setInputTarget(value)
|
||||
}}
|
||||
onSubmit={confirm}
|
||||
placeholder="Worktree name"
|
||||
placeholder="Project copy name"
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={theme.text.formfield.default}
|
||||
focusedTextColor={theme.text.formfield.default}
|
||||
@@ -74,17 +74,17 @@ export function DialogWorktreeName(props: { onConfirm: (name: string) => void })
|
||||
enter <span style={{ fg: theme.text.subdued }}>submit</span>
|
||||
</text>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcuts.get("dialog.worktree.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogWorktreeName.show = (dialog: DialogContext) =>
|
||||
DialogProjectCopyName.show = (dialog: DialogContext) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogWorktreeName onConfirm={resolve} />,
|
||||
() => <DialogProjectCopyName onConfirm={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
@@ -1,18 +1,30 @@
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
|
||||
// Holds one in-progress draft per tab across Prompt remounts. A draft is
|
||||
// consumed on take: restoring it moves it out of the stash, so a stale copy
|
||||
// never shadows newer input.
|
||||
// Holds one in-progress draft per slot across Prompt remounts. The undefined
|
||||
// key is the default single global slot that follows focus across tabs; the
|
||||
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
|
||||
// were written in. A draft is consumed on take: restoring it moves it out of
|
||||
// the stash, so a stale copy never shadows newer input.
|
||||
export type DraftEntry = { prompt: PromptInfo; cursor: number }
|
||||
|
||||
const byTab = new Map<string | undefined, DraftEntry>()
|
||||
let global: DraftEntry | undefined
|
||||
const byTab = new Map<string, DraftEntry>()
|
||||
|
||||
export function takeDraft(sessionID: string | undefined) {
|
||||
const entry = byTab.get(sessionID)
|
||||
byTab.delete(sessionID)
|
||||
export function takeDraft(key: string | undefined) {
|
||||
if (key === undefined) {
|
||||
const entry = global
|
||||
global = undefined
|
||||
return entry
|
||||
}
|
||||
const entry = byTab.get(key)
|
||||
byTab.delete(key)
|
||||
return entry
|
||||
}
|
||||
|
||||
export function saveDraft(sessionID: string | undefined, entry: DraftEntry) {
|
||||
byTab.set(sessionID, entry)
|
||||
export function saveDraft(key: string | undefined, entry: DraftEntry) {
|
||||
if (key === undefined) {
|
||||
global = entry
|
||||
return
|
||||
}
|
||||
byTab.set(key, entry)
|
||||
}
|
||||
|
||||
@@ -678,9 +678,10 @@ export function Prompt(props: PromptProps) {
|
||||
// instance belongs to exactly one tab. Reading props.sessionID lazily would
|
||||
// observe the *next* route during onCleanup and stash under the wrong tab.
|
||||
const stashSessionID = props.sessionID
|
||||
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
|
||||
|
||||
onMount(() => {
|
||||
const saved = takeDraft(stashSessionID)
|
||||
const saved = takeDraft(stashKey())
|
||||
if (store.prompt.text) return
|
||||
if (saved && saved.prompt.text) {
|
||||
input.setText(saved.prompt.text)
|
||||
@@ -693,7 +694,7 @@ export function Prompt(props: PromptProps) {
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
||||
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
@@ -1842,7 +1843,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new worktree)
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
@@ -23,16 +23,17 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
const projectID = await resolveProjectID()
|
||||
if (!projectID) return
|
||||
setCreating(true)
|
||||
setProgress("Creating worktree")
|
||||
setProgress("Creating copy")
|
||||
try {
|
||||
const result = await client.api.worktree.create({
|
||||
const result = await client.api.projectCopy.create({
|
||||
projectID,
|
||||
strategy: "git",
|
||||
location: { directory: data.location.info()?.directory || paths.cwd },
|
||||
strategy: "git_worktree",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
name,
|
||||
})
|
||||
const directory = result.directory
|
||||
if (!directory) throw new Error("No worktree directory returned")
|
||||
if (!directory) throw new Error("No project copy directory returned")
|
||||
|
||||
// Call a location-based route to initialize it before moving on.
|
||||
await client.api.location.get({ location: { directory } })
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
@@ -409,20 +408,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
const fixture = tabs.detail?.(tab.sessionID)
|
||||
if (fixture !== undefined) return fixture
|
||||
if (fixture !== undefined) return Locale.takeWidth(fixture, titleWidth())
|
||||
const value = session()
|
||||
const currentProject = project()
|
||||
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
|
||||
const vcs = value ? data.location.vcs.info(value.location) : undefined
|
||||
const location = value ? data.location.info(value.location) : undefined
|
||||
const worktree = !!location && location.project.directory !== location.project.canonical
|
||||
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
})
|
||||
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
|
||||
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
|
||||
const detailFades = createMemo(
|
||||
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const background = createMemo(() => {
|
||||
if (selected()) return theme.background.action.primary.selected
|
||||
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||
@@ -464,10 +453,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const detailTextColor = (index: number) =>
|
||||
detailFades()
|
||||
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
|
||||
: detailColor()
|
||||
const glows = () => status().glows
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
@@ -685,11 +670,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
<Show when={detailFades()} fallback={visibleDetail()}>
|
||||
<For each={visibleDetailParts()}>
|
||||
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
{detail()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -192,9 +192,13 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Debugging settings" }),
|
||||
experimental: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||
description: "Experimental features that may change or be removed at any time",
|
||||
}),
|
||||
experimental: Schema.optional(
|
||||
Schema.Struct({
|
||||
tab_drafts: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Keep unsent prompt drafts on the tab where they were written",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Experimental features that may change or be removed at any time" }),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
||||
cursor: Schema.optional(Cursor),
|
||||
|
||||
@@ -243,10 +243,10 @@ export const Definitions = {
|
||||
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
||||
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
|
||||
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
|
||||
"dialog.worktree.generate": keybind("tab", "Generate worktree name"),
|
||||
"dialog.move_session.new": keybind("ctrl+m", "New worktree"),
|
||||
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
|
||||
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh worktrees"),
|
||||
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
|
||||
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
|
||||
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
|
||||
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
|
||||
"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"),
|
||||
|
||||
@@ -39,7 +39,7 @@ import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { nonEmptyToolContent } from "../util/tool-display"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
@@ -94,7 +94,7 @@ type Store = {
|
||||
location: Record<string, LocationData>
|
||||
}
|
||||
|
||||
export function locationKey(location: LocationRef) {
|
||||
function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
||||
@@ -458,9 +458,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
break
|
||||
}
|
||||
case "worktree.resolved": {
|
||||
case "project.directory.resolved": {
|
||||
for (const [sessionID, info] of Object.entries(store.session.info)) {
|
||||
const adopted = Worktree.adopt(
|
||||
const adopted = ProjectDirectories.adopt(
|
||||
{ projectID: info.projectID, directory: info.location.directory },
|
||||
event.data,
|
||||
)
|
||||
@@ -1214,9 +1214,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
default() {
|
||||
return defaultLocation()
|
||||
},
|
||||
syncInfo(ref?: LocationRef) {
|
||||
async sync(ref?: LocationRef) {
|
||||
const current = ref ?? defaultLocation()
|
||||
return sync.run(`location:${locationKey(current)}`, async () => {
|
||||
await sync.run(`location:${locationKey(current)}`, async () => {
|
||||
const location = await client.api.location.get({ location: locationQuery(current) })
|
||||
const key = locationKey(location)
|
||||
if (!store.location[key]) setStore("location", key, {})
|
||||
@@ -1225,9 +1225,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
||||
}
|
||||
})
|
||||
},
|
||||
async sync(ref?: LocationRef) {
|
||||
await result.location.syncInfo(ref)
|
||||
const location = ref ?? defaultLocation()
|
||||
await Promise.all([
|
||||
result.location.vcs.sync(location),
|
||||
|
||||
@@ -13,16 +13,6 @@ export function sessionTabShortcutLabel(index: number) {
|
||||
return "·"
|
||||
}
|
||||
|
||||
export function sessionTabDetail(
|
||||
project: string,
|
||||
current: string | undefined,
|
||||
defaultBranch: string | undefined,
|
||||
worktree: boolean,
|
||||
) {
|
||||
const branch = worktree && current !== defaultBranch ? current : undefined
|
||||
return branch && project ? `${project} ⎇ ${branch}` : (branch ?? project)
|
||||
}
|
||||
|
||||
export type SessionTabHistory = {
|
||||
entries: readonly string[]
|
||||
index: number
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { locationKey, useData } from "./data"
|
||||
import { useData } from "./data"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
@@ -159,9 +159,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
})
|
||||
|
||||
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
|
||||
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
|
||||
// the first connection slots and switches still render from a warm cache.
|
||||
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
|
||||
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
|
||||
// connection slots and switches still render from a warm cache.
|
||||
const openTabSessions = createMemo(() =>
|
||||
state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
@@ -171,25 +171,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (client.connection.status() !== "connected") return
|
||||
const signature = openTabSessions()
|
||||
if (signature === "") return
|
||||
const sessionIDs = signature.split("\n")
|
||||
const sessionIDs = openTabSessions()
|
||||
if (sessionIDs === "") return
|
||||
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
|
||||
let stale = false
|
||||
void (async () => {
|
||||
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
|
||||
if (stale) return
|
||||
const locations = new Map(
|
||||
sessionIDs
|
||||
.map((sessionID) => data.session.get(sessionID)?.location)
|
||||
.filter((location) => location !== undefined)
|
||||
.map((location) => [locationKey(location), location]),
|
||||
)
|
||||
await Promise.allSettled(
|
||||
Array.from(locations.values(), (location) =>
|
||||
Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)]),
|
||||
),
|
||||
)
|
||||
})()
|
||||
const timer = setTimeout(async () => {
|
||||
const sessions = state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
|
||||
@@ -94,7 +94,6 @@ export function Composer(props: ComposerProps) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => props.open,
|
||||
priority: 1,
|
||||
commands: [
|
||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||
|
||||
@@ -50,7 +50,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => composer.active("shell"),
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "composer.shell.up",
|
||||
|
||||
@@ -164,7 +164,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => composer.active("subagents"),
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "composer.subagent.up",
|
||||
|
||||
@@ -23,11 +23,7 @@ const sessions = {
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
||||
|
||||
async function renderComposer(
|
||||
defaultTab: "subagents" | "shell",
|
||||
keybinds: Partial<TuiKeybind.Keybinds>,
|
||||
focusedTextarea = false,
|
||||
) {
|
||||
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
|
||||
const events = createEventStream()
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
@@ -73,21 +69,7 @@ async function renderComposer(
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
.then(() => ready.resolve(), ready.reject)
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
|
||||
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AppExit() {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "app.exit", title: "Exit", group: "System", run: () => {} }],
|
||||
}))
|
||||
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
|
||||
return null
|
||||
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
@@ -106,7 +88,6 @@ async function renderComposer(
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
@@ -173,20 +154,6 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("configured composer bindings work with a focused textarea", async () => {
|
||||
const composer = await renderComposer("subagents", { "composer.shell.kill": "ctrl+u" }, true)
|
||||
try {
|
||||
composer.app.mockInput.pressArrow("right")
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||
composer.app.mockInput.pressKey("u", { ctrl: true })
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function session(id: string, title: string, parentID?: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -11,20 +11,11 @@ import {
|
||||
reopenSessionTab,
|
||||
seedSessionTabMotion,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabShortcutLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
test("appends the branch to the project detail", () => {
|
||||
expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode ⎇ feature/sidebar")
|
||||
expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode ⎇ feature/sidebar")
|
||||
expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode")
|
||||
expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode")
|
||||
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
|
||||
})
|
||||
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
"1",
|
||||
|
||||
@@ -28,14 +28,7 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
||||
|
||||
async function renderSessionTabs(
|
||||
initialSessionID: string,
|
||||
options?: {
|
||||
state?: string
|
||||
title?: string
|
||||
home?: boolean
|
||||
persisted?: string[]
|
||||
sessionGate?: Promise<void>
|
||||
sessionDirectories?: Record<string, string>
|
||||
},
|
||||
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
|
||||
) {
|
||||
const temporary = options?.state ? undefined : await tmpdir()
|
||||
const state = options?.state ?? temporary!.path
|
||||
@@ -52,25 +45,7 @@ async function renderSessionTabs(
|
||||
}
|
||||
const events = createEventStream()
|
||||
const sessions: string[] = []
|
||||
const locations: string[] = []
|
||||
const vcsLocations: string[] = []
|
||||
const calls = createFetch(async (url) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
locations.push(requested)
|
||||
return json({
|
||||
directory: requested,
|
||||
project: { id: "project", directory: requested, canonical: directory },
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/vcs") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
vcsLocations.push(requested)
|
||||
return json({
|
||||
location: { directory: requested },
|
||||
data: { branch: { current: "main", default: "main" } },
|
||||
})
|
||||
}
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
if (!sessionID) return undefined
|
||||
sessions.push(sessionID)
|
||||
@@ -80,7 +55,7 @@ async function renderSessionTabs(
|
||||
id: sessionID,
|
||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||
projectID: "project",
|
||||
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
@@ -132,8 +107,6 @@ async function renderSessionTabs(
|
||||
route,
|
||||
data,
|
||||
sessions,
|
||||
locations,
|
||||
vcsLocations,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
async destroy() {
|
||||
@@ -164,22 +137,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("loads VCS metadata for each persisted tab location", async () => {
|
||||
const other = `${directory}/other-worktree`
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
sessionDirectories: { second: other },
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.locations.includes(other))
|
||||
await wait(() => setup.vcsLocations.includes(other))
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("stores session tabs for the current working directory by default", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
|
||||
@@ -107,13 +107,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
})
|
||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
if (url.pathname === "/api/experimental/project/proj_test/worktree") {
|
||||
if (request.method === "GET") return json([{ directory: worktree }])
|
||||
if (request.method === "POST") return json({ directory: `${worktree}/created` })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/project/proj_test/worktree/refresh")
|
||||
return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
|
||||
if (url.pathname === "/api/shell")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
|
||||
@@ -3,13 +3,23 @@ import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash"
|
||||
import { emptyPrompt } from "../../src/prompt/history"
|
||||
|
||||
// The Prompt component stashes an unsent draft in onCleanup and takes it back
|
||||
// in onMount across route remounts, keyed by sessionID or undefined for home.
|
||||
// in onMount across route remounts. The key it uses is undefined by default
|
||||
// (one global slot that follows focus across tabs) and the tab identity
|
||||
// (sessionID, or "home") when the tab_drafts experiment is on.
|
||||
|
||||
function draft(text: string, cursor = text.length) {
|
||||
return { prompt: { ...emptyPrompt(), text }, cursor }
|
||||
}
|
||||
|
||||
describe("prompt draft stash", () => {
|
||||
test("global slot follows focus: any tab takes the last stashed draft", () => {
|
||||
const entry = draft("follow me")
|
||||
saveDraft(undefined, entry)
|
||||
expect(takeDraft(undefined)).toBe(entry)
|
||||
// Consumed on take, so a remount never restores a stale copy.
|
||||
expect(takeDraft(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tab-keyed drafts stay on the tab they were written in", () => {
|
||||
const two = draft("notes for session two")
|
||||
saveDraft("ses_two", two)
|
||||
@@ -27,12 +37,25 @@ describe("prompt draft stash", () => {
|
||||
const one = draft("DRAFT-ONE")
|
||||
const home = draft("draft on home")
|
||||
saveDraft("ses_one", one)
|
||||
saveDraft(undefined, home)
|
||||
saveDraft("home", home)
|
||||
|
||||
expect(takeDraft(undefined)).toBe(home)
|
||||
expect(takeDraft("home")).toBe(home)
|
||||
expect(takeDraft("ses_one")).toBe(one)
|
||||
})
|
||||
|
||||
test("global and tab slots never leak into each other when the experiment toggles mid-draft", () => {
|
||||
const global = draft("stashed before enabling tab_drafts")
|
||||
const keyed = draft("stashed after enabling tab_drafts")
|
||||
saveDraft(undefined, global)
|
||||
saveDraft("ses_a", keyed)
|
||||
|
||||
// A keyed lookup must not surface the global draft on the wrong tab...
|
||||
expect(takeDraft("ses_b")).toBeUndefined()
|
||||
// ...and the global slot must not surface a tab's draft.
|
||||
expect(takeDraft(undefined)).toBe(global)
|
||||
expect(takeDraft("ses_a")).toBe(keyed)
|
||||
})
|
||||
|
||||
test("a newer draft for the same slot replaces the older one", () => {
|
||||
saveDraft("ses_a", draft("first"))
|
||||
const second = draft("second")
|
||||
|
||||
@@ -15,20 +15,29 @@ description: "Get started with OpenCode."
|
||||
|
||||
## Install
|
||||
|
||||
### Install script
|
||||
<CodeGroup>
|
||||
|
||||
```bash
|
||||
```bash npm
|
||||
npm install -g @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash bun
|
||||
bun install -g --trust @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn global add @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash curl
|
||||
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash
|
||||
```
|
||||
|
||||
You can also install it with the following package managers.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">```bash npm install -g @opencode-ai/cli@next ```</Tab>
|
||||
<Tab title="bun">```bash bun install -g --trust @opencode-ai/cli@next ```</Tab>
|
||||
<Tab title="pnpm">```bash pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next ```</Tab>
|
||||
<Tab title="Yarn">```bash yarn global add @opencode-ai/cli@next ```</Tab>
|
||||
</Tabs>
|
||||
</CodeGroup>
|
||||
|
||||
The package uses a trusted postinstall script to select the native `opencode2` binary for your platform. The Bun and pnpm
|
||||
commands above explicitly allow that script to run.
|
||||
|
||||
Reference in New Issue
Block a user