mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 08:42:00 +00:00
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:
parent
f607ca4c72
commit
96d84626f8
14 changed files with 553 additions and 55 deletions
8
.changeset/stable-project-labels.md
Normal file
8
.changeset/stable-project-labels.md
Normal 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.
|
||||
|
|
@ -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>>) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
90
packages/app/src/workspaces/create.test.ts
Normal file
90
packages/app/src/workspaces/create.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
20
packages/app/src/workspaces/create.ts
Normal file
20
packages/app/src/workspaces/create.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
253
packages/tui/test/cli/tui/prompt-move.test.tsx
Normal file
253
packages/tui/test/cli/tui/prompt-move.test.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue