refactor(project): drop persisted Project.sandboxes column

The Project row stored a sandboxes string array that duplicated what git
already tracks. The /experimental/worktree GET handler now reads from
`Worktree.list()` (git worktree list --porcelain) instead, the write-side
addSandbox/removeSandbox bookkeeping is gone, and the column is dropped
from the schema with a Drizzle migration. The app maps the live list onto
project.worktrees client-side instead of reading project.sandboxes.
This commit is contained in:
Kit Langton 2026-05-11 11:07:36 -04:00
commit c4132543c3
31 changed files with 1866 additions and 519 deletions

View file

@ -284,7 +284,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
for (const project of projects) {
let at = 0
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const dirs = [project.worktree, ...(project.worktrees ?? [])]
for (const directory of dirs) {
const sessions = sync.child(directory, { bootstrap: false })[0].session
for (const session of sessions) {

View file

@ -280,14 +280,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
return layout.projects.list().find((p) => p.worktree === directory || p.worktrees?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
const dirs = [current.worktree, ...(current.worktrees ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})

View file

@ -4,6 +4,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import { produce } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
@ -342,6 +343,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
WorktreeState.pending(createdWorktree.directory)
globalSync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.worktree === projectDirectory)
if (!project) return
project.worktrees = [
createdWorktree.directory,
...(project.worktrees ?? []).filter((worktree) => worktree !== createdWorktree.directory),
]
}),
)
sessionDirectory = createdWorktree.directory
}

View file

@ -144,7 +144,7 @@ export function SessionHeader() {
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
return layout.projects.list().find((p) => p.worktree === directory || p.worktrees?.includes(directory))
})
const name = createMemo(() => {
const current = project()

View file

@ -20,8 +20,8 @@ export function NewSessionView(props: NewSessionViewProps) {
const sdk = useSDK()
const language = useLanguage()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const worktrees = createMemo(() => sync.project?.worktrees ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...worktrees(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection

View file

@ -2,7 +2,6 @@ import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
@ -27,7 +26,7 @@ import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } fr
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import type { ProjectInfo, ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
@ -38,7 +37,7 @@ type GlobalStore = {
ready: boolean
error?: InitError
path: Path
project: Project[]
project: ProjectInfo[]
session_todo: {
[sessionID: string]: Todo[]
}
@ -48,6 +47,10 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
const isProjectList = (input: unknown): input is ProjectInfo[] => Array.isArray(input)
const isProjectUpdate = (input: unknown): input is (draft: ProjectInfo[]) => ProjectInfo[] =>
typeof input === "function"
export const loadSessionsQueryKey = (directory: string) => [directory, "loadSessions"] as const
export const mcpQueryKey = (directory: string) => [directory, "mcp"] as const
@ -122,13 +125,13 @@ function createGlobalSync() {
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
const setProjects = (next: ProjectInfo[] | ((draft: ProjectInfo[]) => ProjectInfo[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
if (input[0] === "project" && isProjectList(input[1])) {
setProjects(input[1])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@ -151,8 +154,8 @@ function createGlobalSync() {
}))
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
if (input[0] === "project" && (isProjectList(input[1]) || isProjectUpdate(input[1]))) {
setProjects(input[1])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)

View file

@ -3,7 +3,6 @@ import type {
OpencodeClient,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
@ -15,7 +14,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types"
import type { ProjectInfo, State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
@ -24,7 +23,7 @@ import { loadMcpQuery } from "../global-sync"
type GlobalStore = {
ready: boolean
path: Path
project: Project[]
project: ProjectInfo[]
session_todo: {
[sessionID: string]: Todo[]
}
@ -94,12 +93,21 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
queryKey: ["project"],
queryFn: () =>
retry(() =>
sdk.project.list().then((x) => {
return (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
sdk.project.list().then(async (x) => {
return Promise.all(
(x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
.map(async (project) => ({
...project,
worktrees: await sdk.worktree
.list({ directory: project.worktree })
.then((x) => x.data ?? [])
.catch(() => []),
})),
)
}),
),
})
@ -140,8 +148,8 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {})
}
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
function projectID(directory: string, projects: ProjectInfo[]) {
return projects.find((project) => project.worktree === directory || project.worktrees?.includes(directory))?.id
}
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
@ -207,7 +215,7 @@ export async function bootstrapDirectory(input: {
global: {
config: Config
path: Path
project: Project[]
project: ProjectInfo[]
provider: ProviderListResponse
}
queryClient: QueryClient

View file

@ -4,14 +4,13 @@ import type {
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import type { ProjectInfo, State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
@ -20,8 +19,8 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
project: ProjectInfo[]
setGlobalProject: (next: ProjectInfo[] | ((draft: ProjectInfo[]) => ProjectInfo[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
@ -30,7 +29,7 @@ export function applyGlobalEvent(input: {
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const properties = input.event.properties as ProjectInfo
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
input.setGlobalProject(

View file

@ -8,6 +8,7 @@ import type {
Part,
Path,
PermissionRequest,
Project,
ProviderListResponse,
QuestionRequest,
Session,
@ -30,6 +31,10 @@ export type ProjectMeta = {
}
}
export type ProjectInfo = Project & {
worktrees?: string[]
}
export type State = {
status: "loading" | "partial" | "complete"
agent: Agent[]

View file

@ -1,4 +1,5 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type { ProjectInfo } from "./types"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@ -27,7 +28,7 @@ export function normalizeProviderList(input: ProviderListResponse): ProviderList
}
}
export function sanitizeProject(project: Project) {
export function sanitizeProject(project: ProjectInfo) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,

View file

@ -6,8 +6,8 @@ import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import type { ProjectInfo } from "./global-sync/types"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@ -51,7 +51,7 @@ type TabHandoff = {
at: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type LocalProject = Partial<ProjectInfo> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
@ -404,9 +404,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
for (const worktree of project.worktrees ?? []) {
map.set(worktree, project.worktree)
}
}
return map

View file

@ -560,8 +560,8 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key))
if (sandbox) return sandbox
const worktree = projects.find((p) => p.worktrees?.some((item) => pathKey(item) === key))
if (worktree) return worktree
const direct = projects.find((p) => pathKey(p.worktree) === key)
if (direct) return direct
@ -646,7 +646,7 @@ export default function Layout(props: ParentProps) {
if (!expanded) continue
const key = pathKey(directory)
const project = projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
(item) => pathKey(item.worktree) === key || item.worktrees?.some((worktree) => pathKey(worktree) === key),
)
if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
@ -1223,7 +1223,7 @@ export default function Layout(props: ParentProps) {
const key = pathKey(directory)
const project = layout.projects
.list()
.find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key))
.find((item) => pathKey(item.worktree) === key || item.worktrees?.some((worktree) => pathKey(worktree) === key))
if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find(
@ -1275,7 +1275,7 @@ export default function Layout(props: ParentProps) {
server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root)
let dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
? effectiveWorkspaceOrder(root, [root, ...(project.worktrees ?? [])], store.workspaceOrder[root])
: [root]
const canOpen = (value: string | undefined) => {
if (!value) return false
@ -1514,7 +1514,7 @@ export default function Layout(props: ParentProps) {
produce((draft) => {
const project = draft.find((item) => item.worktree === root)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory)
project.worktrees = (project.worktrees ?? []).filter((worktree) => worktree !== directory)
}),
)
setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory))
@ -1528,7 +1528,7 @@ export default function Layout(props: ParentProps) {
const nextKey = pathKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
? effectiveWorkspaceOrder(root, [root, ...(project.worktrees ?? [])], store.workspaceOrder[root])
: [root]
const valid = dirs.some((item) => pathKey(item) === nextKey)
@ -1862,7 +1862,7 @@ export default function Layout(props: ParentProps) {
function workspaceIds(project: LocalProject | undefined) {
if (!project) return []
const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])]
const dirs = [local, ...(project.worktrees ?? [])]
const active = currentProject()
const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined
const extra =
@ -1954,6 +1954,14 @@ export default function Layout(props: ParentProps) {
})
return [created.directory, ...next]
})
globalSync.set(
"project",
produce((draft) => {
const item = draft.find((item) => item.worktree === project.worktree)
if (!item) return
item.worktrees = [created.directory, ...(item.worktrees ?? []).filter((worktree) => pathKey(worktree) !== key)]
}),
)
globalSync.child(created.directory)
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)

View file

@ -35,7 +35,7 @@ export const ProjectIcon = (props: {
const globalSync = useGlobalSync()
const notification = useNotification()
const permission = usePermission()
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
const dirs = createMemo(() => [props.project.worktree, ...(props.project.worktrees ?? [])])
const unseenCount = createMemo(() =>
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)

View file

@ -0,0 +1 @@
ALTER TABLE `project` DROP COLUMN `sandboxes`;

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,5 @@ export const ProjectTable = sqliteTable("project", {
icon_color: text(),
...Timestamps,
time_initialized: integer(),
sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})

View file

@ -51,7 +51,6 @@ export const Info = Schema.Struct({
icon: optionalOmitUndefined(ProjectIcon),
commands: optionalOmitUndefined(ProjectCommands),
time: ProjectTime,
sandboxes: Schema.Array(Schema.String),
})
.annotate({ identifier: "Project" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@ -83,7 +82,6 @@ export function fromRow(row: Row): Info {
updated: row.time_updated,
initialized: row.time_initialized ?? undefined,
},
sandboxes: row.sandboxes,
commands: row.commands ?? undefined,
}
}
@ -123,9 +121,6 @@ export interface Interface {
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@ -283,7 +278,6 @@ export const layer: Layer.Layer<
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
@ -295,17 +289,6 @@ export const layer: Layer.Layer<
vcs: data.vcs,
time: { ...existing.time, updated: Date.now() },
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
fs.exists(s).pipe(
Effect.orDie,
Effect.map((exists) => (exists ? s : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d
@ -321,7 +304,6 @@ export const layer: Layer.Layer<
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
})
.onConflictDoUpdate({
@ -335,7 +317,6 @@ export const layer: Layer.Layer<
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
},
})
@ -441,54 +422,6 @@ export const layer: Layer.Layer<
yield* InstanceState.get(initState)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
data.sandboxes,
(dir) =>
fs.isDir(dir).pipe(
Effect.orDie,
Effect.map((ok) => (ok ? dir : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
return Service.of({
init,
fromDirectory,
@ -498,9 +431,6 @@ export const layer: Layer.Layer<
update,
initGit,
setInitialized,
sandboxes,
addSandbox,
removeSandbox,
})
}),
)

View file

@ -145,7 +145,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
OpenApi.annotations({
identifier: "worktree.list",
summary: "List worktrees",
description: "List all sandbox worktrees for the current project.",
description: "List all git worktrees for the current project.",
}),
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {

View file

@ -1,9 +1,7 @@
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@opencode-ai/core/effect-zod"
@ -20,7 +18,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const agents = yield* Agent.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
@ -93,8 +90,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
})
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
return yield* project.sandboxes(ctx.project.id)
return yield* worktreeSvc.list().pipe(Effect.map((items) => items.map((item) => item.directory)))
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
@ -106,9 +102,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})

View file

@ -173,7 +173,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
time_created: data.time?.created ?? now,
time_updated: data.time?.updated ?? now,
time_initialized: data.time?.initialized,
sandboxes: data.sandboxes ?? [],
commands: data.commands,
})
}

View file

@ -158,12 +158,7 @@ type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
| AppFileSystem.Service
| Path.Path
| ChildProcessSpawner.ChildProcessSpawner
| Git.Service
| Project.Service
| InstanceStore.Service
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | InstanceStore.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@ -172,7 +167,6 @@ export const layer: Layer.Layer<
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const gitSvc = yield* Git.Service
const project = yield* Project.Service
const store = yield* InstanceStore.Service
const git = Effect.fnUntraced(
@ -233,8 +227,6 @@ export const layer: Layer.Layer<
if (created.code !== 0) {
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
}
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
})
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
@ -312,7 +304,7 @@ export const layer: Layer.Layer<
return text
.split("\n")
.map((line) => line.trim())
.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
.reduce<{ path?: string; branch?: string; prunable?: boolean }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
@ -323,6 +315,9 @@ export const layer: Layer.Layer<
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
if (line === "prunable") {
current.prunable = true
}
return acc
}, [])
}
@ -354,6 +349,7 @@ export const layer: Layer.Layer<
const primaryName = pathSvc.basename(primary).toLowerCase()
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
Effect.gen(function* () {
if (entry.prunable) return undefined
if (!entry.path) return undefined
const directory = yield* canonical(entry.path)
if (directory === primary) return undefined
@ -612,7 +608,6 @@ export const layer: Layer.Layer<
export const appLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)

View file

@ -324,7 +324,6 @@ function insertProject(id: ProjectID, worktree: string) {
name: null,
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.run(),
)

View file

@ -82,7 +82,6 @@ it.live("makeRuntime inherits InstanceRef from the current fiber", () =>
id: ProjectID.global,
worktree: testDirectory,
time: { created: 0, updated: 0 },
sandboxes: [],
},
}),
),

View file

@ -55,7 +55,6 @@ function ensureGlobal() {
worktree: "/",
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.onConflictDoNothing()
.run(),

View file

@ -153,7 +153,6 @@ describe("Project.fromDirectory with worktrees", () => {
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
expect(project.sandboxes).not.toContain(tmp.path)
})
test("should set worktree to root when called from a worktree", async () => {
@ -167,8 +166,6 @@ describe("Project.fromDirectory with worktrees", () => {
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath)
expect(project.sandboxes).toContain(worktreePath)
expect(project.sandboxes).not.toContain(tmp.path)
} finally {
await $`git worktree remove ${worktreePath}`
.cwd(tmp.path)
@ -220,34 +217,6 @@ describe("Project.fromDirectory with worktrees", () => {
await $`rm -rf ${bare} ${clone}`.quiet().nothrow()
}
})
test("should accumulate multiple worktrees in sandboxes", async () => {
await using tmp = await tmpdir({ git: true })
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2")
try {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await run((svc) => svc.fromDirectory(worktree1))
const { project } = await run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1)
expect(project.sandboxes).toContain(worktree2)
expect(project.sandboxes).not.toContain(tmp.path)
} finally {
await $`git worktree remove ${worktree1}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
await $`git worktree remove ${worktree2}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
}
})
})
describe("Project.discover", () => {
@ -485,39 +454,6 @@ describe("Project.setInitialized", () => {
})
})
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-test")
await run((svc) => svc.addSandbox(project.id, sandboxDir))
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await run((svc) => svc.removeSandbox(project.id, sandboxDir))
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
})
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await run((svc) => svc.addSandbox(project.id, sandboxDir))
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})
describe("Project.fromDirectory with bare repos", () => {
test("worktree from bare repo should cache in bare repo, not parent", async () => {
await using tmp = await tmpdir({ git: true })

View file

@ -219,7 +219,7 @@ describe("Worktree", () => {
expect(list).toContainEqual({
name: path.basename(parent),
branch,
directory: directory.toLowerCase(),
directory: process.platform === "win32" ? directory.toLowerCase() : directory,
})
yield* svc.remove({ directory: target })

View file

@ -57,12 +57,11 @@ describe("experimental HttpApi", () => {
})
const headers = { "x-opencode-directory": tmp.path }
const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = await Promise.all([
const [consoleState, consoleOrgs, toolList, toolIDs, resources] = await Promise.all([
app().request(ExperimentalPaths.console, { headers }),
app().request(ExperimentalPaths.consoleOrgs, { headers }),
app().request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, { headers }),
app().request(ExperimentalPaths.toolIDs, { headers }),
app().request(ExperimentalPaths.worktree, { headers }),
app().request(ExperimentalPaths.resource, { headers }),
])
@ -87,9 +86,6 @@ describe("experimental HttpApi", () => {
expect(toolIDs.status).toBe(200)
expect(await toolIDs.json()).toContain("bash")
expect(worktrees.status).toBe(200)
expect(await worktrees.json()).toEqual([])
expect(resources.status).toBe(200)
expect(await resources.json()).toEqual({})
})
@ -172,10 +168,6 @@ describe("experimental HttpApi", () => {
expect(info).toMatchObject({ name: "api-test", branch: "opencode/api-test" })
await waitReady(info.directory)
const listed = await app().request(ExperimentalPaths.worktree, { headers })
expect(listed.status).toBe(200)
expect(await listed.json()).toContain(info.directory)
if (process.platform !== "win32") {
const reset = await app().request(ExperimentalPaths.worktreeReset, {
method: "POST",
@ -195,9 +187,6 @@ describe("experimental HttpApi", () => {
expect(removed.status).toBe(200)
expect(await removed.json()).toBe(true)
const afterRemove = await app().request(ExperimentalPaths.worktree, { headers })
expect(afterRemove.status).toBe(200)
expect(await afterRemove.json()).toEqual([])
expect(await Bun.file(info.directory).exists()).toBe(false)
})
})

View file

@ -130,7 +130,6 @@ describe("JSON to SQLite migration", () => {
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
expect(projects[0].worktree).toBe("/test/path")
expect(projects[0].name).toBe("Test Project")
expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
})
test("uses filename for project id when JSON has different value", async () => {

View file

@ -1236,7 +1236,7 @@ export class Worktree extends HeyApiClient {
/**
* List worktrees
*
* List all sandbox worktrees for the current project.
* List all git worktrees for the current project.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {

View file

@ -5,6 +5,12 @@ export type ClientOptions = {
}
export type Event =
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@ -24,10 +30,6 @@ export type Event =
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@ -75,8 +77,6 @@ export type Event =
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
export type OAuth = {
type: "oauth"
@ -103,6 +103,61 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type PermissionRequest = {
id: string
sessionID: string
@ -280,61 +335,6 @@ export type SessionStatus =
type: "busy"
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type Project = {
id: string
worktree: string
@ -356,7 +356,6 @@ export type Project = {
updated: number
initialized?: number
}
sandboxes: Array<string>
}
export type Pty = {
@ -779,6 +778,12 @@ export type GlobalEvent = {
project?: string
workspace?: string
payload:
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@ -798,10 +803,6 @@ export type GlobalEvent = {
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@ -849,8 +850,6 @@ export type GlobalEvent = {
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
@ -2331,6 +2330,22 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventGlobalDisposed = {
id: string
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
@ -3057,22 +3072,6 @@ export type EventSessionNextCompactionEnded = {
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventGlobalDisposed = {
id: string
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type SessionInfo = {
id: string
parentID?: string

View file

@ -1015,7 +1015,7 @@
}
}
},
"description": "List all sandbox worktrees for the current project.",
"description": "List all git worktrees for the current project.",
"summary": "List worktrees",
"x-codeSamples": [
{
@ -8878,6 +8878,24 @@
"schemas": {
"Event": {
"anyOf": [
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/EventTuiToastShow1"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
{
"$ref": "#/components/schemas/EventGlobalDisposed"
},
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
@ -8935,18 +8953,6 @@
{
"$ref": "#/components/schemas/EventSessionCompacted"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/EventTuiToastShow1"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventMcpToolsChanged"
},
@ -9087,12 +9093,6 @@
},
{
"$ref": "#/components/schemas/EventSessionNextCompactionEnded"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
{
"$ref": "#/components/schemas/EventGlobalDisposed"
}
]
},
@ -9173,6 +9173,140 @@
}
]
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.prompt.append"]
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.command.execute"]
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.toast.show"]
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["message", "variant"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.session.select"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses",
"description": "Session ID to navigate to"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"PermissionRequest": {
"type": "object",
"properties": {
@ -9632,140 +9766,6 @@
}
]
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.prompt.append"]
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.command.execute"]
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.toast.show"]
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["message", "variant"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.session.select"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses",
"description": "Session ID to navigate to"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@ -9825,15 +9825,9 @@
},
"required": ["created", "updated"],
"additionalProperties": false
},
"sandboxes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["id", "worktree", "time", "sandboxes"],
"required": ["id", "worktree", "time"],
"additionalProperties": false
},
"Pty": {
@ -11145,6 +11139,24 @@
},
"payload": {
"anyOf": [
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
{
"$ref": "#/components/schemas/EventGlobalDisposed"
},
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
@ -11202,18 +11214,6 @@
{
"$ref": "#/components/schemas/EventSessionCompacted"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventMcpToolsChanged"
},
@ -11355,12 +11355,6 @@
{
"$ref": "#/components/schemas/EventSessionNextCompactionEnded"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
{
"$ref": "#/components/schemas/EventGlobalDisposed"
},
{
"$ref": "#/components/schemas/SyncEventMessageUpdated"
},
@ -15859,6 +15853,42 @@
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"EventServerConnected": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["server.connected"]
},
"properties": {
"type": "object",
"properties": {}
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventGlobalDisposed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["global.disposed"]
},
"properties": {
"type": "object",
"properties": {}
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventServerInstanceDisposed": {
"type": "object",
"properties": {
@ -18062,42 +18092,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventServerConnected": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["server.connected"]
},
"properties": {
"type": "object",
"properties": {}
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventGlobalDisposed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["global.disposed"]
},
"properties": {
"type": "object",
"properties": {}
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"SessionInfo": {
"type": "object",
"properties": {