fix(core): keep project labels stable across clones (#45735)

Keep shared project labels stable across clones while explicitly using each selected checkout for worktree creation and setup. Preserve real directory rename handling and cover the TUI Home and app workspace creation paths.
This commit is contained in:
Kit Langton 2026-08-27 17:35:18 -04:00 committed by GitHub
parent f607ca4c72
commit 96d84626f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 553 additions and 55 deletions

View file

@ -0,0 +1,8 @@
---
"@opencode-ai/core": patch
"@opencode-ai/app": patch
---
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.

View file

@ -25,6 +25,7 @@ for (const viewport of [
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await testInfo.attach("creating-worktree", {
@ -184,6 +185,7 @@ test("restores the draft after closing and revisiting a pending session that fai
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const project = {
@ -216,7 +218,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) calls.push("worktree")
if (path === `/api/worktree/${projectID}`) {
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
@ -274,7 +279,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, calls, creates, prompts }
return { worktree, worktreeRequests, calls, creates, prompts }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {

View file

@ -1,5 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Session } from "@opencode-ai/schema/session"
import { startTransition } from "solid-js"
@ -13,6 +12,7 @@ import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { createWorktree } from "@/workspaces/create"
import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
@ -192,25 +192,17 @@ async function resolveSessionDirectory(input: {
if (input.worktree === "main") return input.projectDirectory
if (input.worktree !== "create") return input.worktree
return input.serverSDK.api.worktree
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
})
.then(async (created) => {
await input.serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
})
.catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
return createWorktree({
api: input.serverSDK.api,
directory: input.projectDirectory,
project: input.data.location.info({ directory: input.projectDirectory })?.project,
branch: input.branch,
}).catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
})
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {

View file

@ -1,6 +1,6 @@
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { getFilename } from "@opencode-ai/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
@ -11,6 +11,7 @@ import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
import { createWorktree } from "@/workspaces/create"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
@ -55,7 +56,14 @@ export function SessionWorkspaceMenu(props: {
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
const destination =
selection === "create"
? await createWorktree({
api: sdk.api,
directory: props.directory,
project: data.location.info({ directory: props.directory })?.project,
})
: selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
@ -124,13 +132,3 @@ export function SessionWorkspaceMenu(props: {
</Menu>
)
}
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}

View file

@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "POST") return Response.json({ directory: "/created" })
return Response.json({ directory: new URL(request.url).searchParams.get("location[directory]"), project })
},
{ preconnect() {} },
),
})
expect(
await createWorktree({
api,
directory: input.directory,
project: input.cached ? project : undefined,
branch: "clone-only",
}),
).toBe("/created")
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
)
expect(
requests
.filter((request) => request.method === "GET")
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
})
test("does not fall back to a shared project when location lookup fails", async () => {
const requests: Request[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push(new Request(input, init))
return Response.json({ message: "unavailable" }, { status: 503 })
},
{ preconnect() {} },
),
})
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status: 503 },
})
expect(requests.map((request) => request.method)).toEqual(["GET"])
})
})

View file

@ -0,0 +1,20 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
directory: string
project?: LocationGetOutput["project"]
branch?: string
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
projectID: project.id,
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
await input.api.location.get({ location: { directory: created.directory } })
return created.directory
}

View file

@ -41,6 +41,7 @@ export interface Resolved {
readonly previous?: ID
readonly id: ID
readonly directory: AbsolutePath
// This checkout's main directory; the stored project canonical may be another clone.
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
@ -110,11 +111,17 @@ const layer = Layer.effect(
.get()
.pipe(Effect.orDie)
yield* upsertProject(db, project).pipe(Effect.orDie)
if (previous && previous.canonical !== project.canonical) {
// Clones share a project ID; only replace a canonical directory that is gone.
if (
previous &&
previous.canonical !== project.canonical &&
!(yield* fs.exists(previous.canonical).pipe(Effect.orElseSucceed(() => true)))
) {
const row = yield* db
.select()
.from(ProjectTable)
.update(ProjectTable)
.set({ worktree: project.canonical })
.where(eq(ProjectTable.id, project.id))
.returning()
.get()
.pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))

View file

@ -51,11 +51,8 @@ export function upsertProject(
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
set: { vcs: vcs ?? null },
setWhere: vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
})
.run()
}

View file

@ -267,20 +267,20 @@ const layer = Layer.effect(
}),
)
const project = yield* db
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
.select({ commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
if (command && project) {
if (command) {
const windows = process.platform === "win32"
yield* processService
.run(
ChildProcess.make(windows ? command : "bash", windows ? [] : ["-lc", command], {
cwd: result.directory,
env: {
OPENCODE_WORKTREE_BASE: project.worktree,
OPENCODE_WORKTREE_BASE: sourceDirectory,
OPENCODE_WORKTREE_PATH: result.directory,
},
extendEnv: true,

View file

@ -352,6 +352,44 @@ describe("Project.resolve", () => {
}),
)
it.live("keeps the canonical project directory when opening another clone", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const main = path.join(tmp.path, "repo")
const clone = path.join(tmp.path, "other-clone")
const linked = path.join(tmp.path, "linked")
yield* Effect.promise(async () => {
await fs.mkdir(main)
await initRepo(main, { commit: true, remote: "git@github.com:owner/repo.git" })
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone).quiet()
await $`git worktree add ${linked} -b linked`.cwd(main).quiet()
})
const project = yield* Project.Service
const bus = yield* Bus.Service
const initial = yield* project.resolve(abs(main))
const updates: Project.Info[] = []
yield* bus.subscribe(ProjectSchema.Event.Updated).pipe(
Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))),
Effect.forkScoped({ startImmediately: true }),
)
for (const directory of [clone, linked, main, clone]) {
const resolved = yield* project.resolve(abs(directory))
expect(resolved.id).toBe(initial.id)
expect(resolved.directory).toBe(abs(directory))
expect(resolved.canonical).toBe(abs(directory === clone ? clone : main))
expect((yield* project.list()).find((item) => item.id === initial.id)?.canonical).toBe(abs(main))
}
yield* Effect.yieldNow
expect(updates).toEqual([])
}),
)
it.live("returns git global for repo with no commits and no remote", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(

View file

@ -92,6 +92,33 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("Session.create", () => {
liveIt.live("preserves the project canonical directory when creating a session in another clone", () =>
withTmp((directory) =>
Effect.gen(function* () {
const main = AbsolutePath.make(path.join(directory, "repo"))
const clone = AbsolutePath.make(path.join(directory, "other-clone"))
yield* Effect.promise(async () => {
await $`git init -q ${main}`.cwd(directory)
await $`git -c user.name=Test -c user.email=test@opencode.test -c commit.gpgsign=false commit --allow-empty -qm root`
.cwd(main)
.quiet()
await $`git remote add origin git@github.com:owner/repo.git`.cwd(main)
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone)
})
const sessions = yield* Session.Service
const projects = yield* Project.Service
const first = yield* sessions.create({ location: Location.Ref.make({ directory: main }) })
const second = yield* sessions.create({ location: Location.Ref.make({ directory: clone }) })
expect(second.projectID).toBe(first.projectID)
expect((yield* projects.list()).find((project) => project.id === first.projectID)?.canonical).toBe(main)
expect((yield* sessions.get(first.id)).location.directory).toBe(main)
expect((yield* sessions.get(second.id)).location.directory).toBe(clone)
}),
),
)
liveIt.live("follows the directory's project identity established after creation", () =>
withTmp((directory) =>
Effect.gen(function* () {

View file

@ -228,6 +228,57 @@ describe("Worktree", () => {
}),
)
projectIt.live("creates worktrees and runs setup from the selected clone", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
const main = abs(path.join(root.path, "repo"))
const clone = abs(path.join(root.path, "other-clone"))
yield* Effect.promise(async () => {
await fs.mkdir(main)
await initRepo(main)
await $`git remote add origin git@github.com:owner/repo.git`.cwd(main).quiet()
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone).quiet()
await $`git -c user.name=Test -c user.email=test@opencode.test -c commit.gpgsign=false commit --allow-empty -m clone`
.cwd(clone)
.quiet()
})
const projects = yield* Project.Service
const worktrees = yield* Worktree.Service
const initial = yield* projects.resolve(main)
const selected = yield* projects.resolve(clone)
yield* projects.update({
projectID: initial.id,
commands: {
start:
"bun -e \"await Bun.write('setup.json', JSON.stringify([process.env.OPENCODE_WORKTREE_BASE, process.env.OPENCODE_WORKTREE_PATH, process.cwd()]))\"",
},
})
const created = yield* worktrees.create({
projectID: selected.id,
strategy: gitWorktree,
from: selected.canonical,
directory: abs(path.join(root.path, "worktrees")),
name: "selected-clone",
})
expect(selected.id).toBe(initial.id)
expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main)
expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe(
yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()),
)
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.json")).json())).toEqual([
clone,
created.directory,
created.directory,
])
}),
)
it.live("creates a git worktree from a selected branch", () =>
Effect.gen(function* () {
const input = yield* setup()

View file

@ -7,28 +7,41 @@ import { useClient } from "../../context/client"
import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const data = useData()
const currentLocation = useLocation()
const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
const [destination, setDestination] = createSignal<MoveSessionSelection>()
function homeLocation() {
const location = currentLocation.ref ?? data.location.default()
return { ...location, directory: location.directory || paths.cwd }
}
async function create(name: string) {
const projectID = await resolveProjectID()
if (!projectID) return
setCreating(true)
setProgress("Creating worktree")
try {
const sessionID = input.sessionID()
const session = sessionID ? await resolveSession(sessionID) : undefined
if (sessionID && !session) throw new Error("Unable to determine current session location")
const location = session?.location ?? homeLocation()
if (!data.location.info(location)) await data.location.syncInfo(location)
const project = data.location.info(location)?.project
if (!project) throw new Error("Unable to determine current project")
const result = await client.api.worktree.create({
projectID,
projectID: project.id,
strategy: "git",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
from: project.canonical,
directory: path.join(paths.worktree, project.id.slice(0, 6)),
name,
})
const directory = result.directory
@ -71,8 +84,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
: {
type: "directory",
directory: data.location.default().directory,
subdirectory: data.location.default().directory !== data.location.info()?.project.directory,
directory: homeLocation().directory,
subdirectory: homeLocation().directory !== data.location.info(homeLocation())?.project.directory,
})
}
onCurrentChange={setDestination}
@ -111,14 +124,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
async function resolveProjectID() {
const projectID = input.projectID()
if (projectID) return projectID
const sessionID = input.sessionID()
if (sessionID) return (await resolveSession(sessionID))?.projectID
const current = data.location.info()
if (sessionID) return input.projectID() ?? (await resolveSession(sessionID))?.projectID
const location = homeLocation()
const current = data.location.info(location)
if (current) return current.project.id
return client.api.project
.current({ location: { directory: data.location.default().directory || paths.cwd } })
.current({ location: { directory: location.directory, workspace: location.workspaceID } })
.then((project) => project.id)
.catch(() => undefined)
}

View file

@ -0,0 +1,253 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import path from "path"
import { InputRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { usePromptMove } from "../../../src/component/prompt/move"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider, useToast } from "../../../src/ui/toast"
import { emptyThemeSource } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
const main = "/tmp/opencode/main"
const clone = "/tmp/opencode/other-clone"
const linked = "/tmp/opencode/linked"
const created = "/tmp/opencode/proj_t/fresh"
test.each([
{ name: "a cached session in another clone", directory: clone, warm: true },
{ name: "an uncached session in a clone subdirectory", directory: `${clone}/packages/tui` },
{ name: "an uncached session in a linked worktree", directory: linked, worktree: linked },
{ name: "a session in a linked worktree subdirectory", directory: `${linked}/packages/tui`, worktree: linked },
{ name: "the home/default location", directory: `${clone}/packages/tui`, home: true },
])("creates from the clone's main worktree for $name", async (input) => {
const fixture = await renderMove(input)
try {
await fixture.data.project.sync()
expect(fixture.data.project.get("proj_test")?.canonical).toBe(main)
if (input.warm) {
await fixture.data.session.sync("ses_clone")
await fixture.data.location.syncInfo({ directory: input.directory })
}
if (!input.home && !input.warm) {
expect(fixture.data.session.get("ses_clone")).toBeUndefined()
expect(fixture.data.location.info({ directory: input.directory })).toBeUndefined()
}
if (!input.home) fixture.location.set({ directory: main })
await fixture.create()
expect(fixture.requests).toEqual([
{ strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" },
])
expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone)
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1)
expect(fixture.reads.session).toBe(input.home ? 0 : 1)
expect(fixture.moves).toEqual(input.home ? [] : [{ directory: created }])
} finally {
fixture.app.renderer.destroy()
}
})
test.each([
{ name: "another clone", launch: main },
{ name: "another project", launch: "/tmp/opencode/elsewhere", launchProjectID: "proj_launch" },
{ name: "another workspace", launch: main, workspaceID: "wrk_clone" },
])("uses Home's selected location instead of launch in $name", async (input) => {
const fixture = await renderMove({ ...input, directory: `${clone}/packages/tui`, home: true })
try {
await fixture.data.location.syncInfo()
const selected = { directory: `${clone}/packages/tui`, workspaceID: input.workspaceID }
fixture.location.set(selected)
expect(fixture.data.location.default().directory).toBe(input.launch)
expect(fixture.data.location.info(selected)).toBeUndefined()
const frame = await fixture.create()
expect(fixture.reads.worktrees).toEqual(["proj_test"])
expect(frame).toContain(clone)
expect(frame.indexOf(clone)).toBeLessThan(frame.indexOf(main))
expect(fixture.requests).toEqual([
{ strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" },
])
expect(fixture.data.location.info(selected)?.project.canonical).toBe(clone)
expect(fixture.moves).toEqual([])
} finally {
fixture.app.renderer.destroy()
}
})
test.each([
{ name: "session", unavailable: "session" as const },
{ name: "location", unavailable: "location" as const },
{ name: "selected Home location", unavailable: "location" as const, home: true, launch: main },
])("does not create from another clone when $name lookup fails", async (input) => {
const fixture = await renderMove({ ...input, directory: `${linked}/packages/tui`, worktree: linked })
try {
if (input.home) fixture.location.set({ directory: `${linked}/packages/tui` })
await fixture.create()
expect(fixture.requests).toEqual([])
expect(fixture.moves).toEqual([])
expect(fixture.toast.currentToast).toMatchObject({ title: "Creating workspace failed", variant: "error" })
expect(fixture.move.creating()).toBe(false)
} finally {
fixture.app.renderer.destroy()
}
})
async function renderMove(input: {
directory: string
worktree?: string
home?: boolean
launch?: string
launchProjectID?: string
unavailable?: "session" | "location"
}) {
const launch = input.launch ?? (input.home ? input.directory : main)
const requests: unknown[] = []
const moves: unknown[] = []
const reads = { session: 0, locations: [] as string[], worktrees: [] as string[] }
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location" || url.pathname === "/api/project/current") {
const directory = url.searchParams.get("location[directory]") ?? launch
const project = {
id: directory === launch ? (input.launchProjectID ?? "proj_test") : "proj_test",
directory: directory === input.directory ? (input.worktree ?? clone) : directory,
canonical:
directory === input.directory || directory === created
? clone
: input.launchProjectID && directory === launch
? launch
: main,
}
if (url.pathname === "/api/project/current") return json(project)
reads.locations.push(directory)
if (input.unavailable === "location" && directory === input.directory)
return json({ message: "Location unavailable" }, { status: 503 })
return json({
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project,
})
}
if (url.pathname === "/api/project")
return json([{ id: "proj_test", canonical: main, time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/session/ses_clone") {
reads.session++
if (input.unavailable === "session") return json({ message: "Session unavailable" }, { status: 404 })
return json({
data: {
id: "ses_clone",
projectID: "proj_test",
location: { directory: input.directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
},
})
}
if (url.pathname === "/api/worktree/proj_test" || url.pathname === "/api/worktree/proj_launch") {
if (request.method === "GET") {
reads.worktrees.push(url.pathname.slice("/api/worktree/".length))
return json(
url.pathname === "/api/worktree/proj_launch"
? [{ directory: launch }]
: [{ directory: main }, { directory: clone }, { directory: linked, strategy: "git" }],
)
}
if (request.method === "POST") {
requests.push(await request.json())
return json({ directory: created })
}
}
if (url.pathname === "/api/worktree/proj_launch/refresh") return new Response(null, { status: 204 })
if (url.pathname === "/api/session/ses_clone/move") {
moves.push(await request.json())
return new Response(null, { status: 204 })
}
return undefined
}, createEventStream())
let data!: ReturnType<typeof useData>
let move!: ReturnType<typeof usePromptMove>
let toast!: ReturnType<typeof useToast>
let location!: ReturnType<typeof useLocation>
function Probe() {
data = useData()
toast = useToast()
location = useLocation()
move = usePromptMove({
projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"),
sessionID: () => (input.home ? undefined : "ses_clone"),
})
return null
}
const app = await testRender(
() => (
<TestTuiContexts cwd={launch}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={launch}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Probe />
</DialogProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
app.renderer.start()
await app.waitFor(() => move !== undefined)
return {
app,
data,
move,
toast,
location,
requests,
moves,
reads,
async create() {
await move.open()
const frame = await app.waitForFrame(
(frame) => frame.includes("Move session") && (frame.includes(clone) || frame.includes(launch)),
)
app.mockInput.pressKey("m", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("Name worktree"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
await app.mockInput.typeText("fresh")
app.mockInput.pressEnter()
if (input.home) {
await app.waitFor(() => move.pendingNew())
await move.getDirectory()
return frame
}
await app.waitFor(() => moves.length > 0 || toast.currentToast !== null)
return frame
},
}
}