From b79e75e01addd03e0ce2c405f19759bd62fcf0ee Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 12 Aug 2026 21:57:30 +0000 Subject: [PATCH] refactor(tui): migrate worktree workflows --- packages/sdk-next/src/index.ts | 2 +- .../sdk-next/test/contract-identity.test.ts | 10 ++++-- .../tui/src/component/dialog-move-session.tsx | 33 +++++++------------ ...copy-name.tsx => dialog-worktree-name.tsx} | 16 ++++----- packages/tui/src/component/prompt/index.tsx | 2 +- packages/tui/src/component/prompt/move.tsx | 9 +++-- packages/tui/src/config/keybind.ts | 8 ++--- packages/tui/src/context/data.tsx | 6 ++-- packages/tui/test/fixture/tui-client.ts | 8 ++++- 9 files changed, 47 insertions(+), 47 deletions(-) rename packages/tui/src/component/{dialog-project-copy-name.tsx => dialog-worktree-name.tsx} (83%) diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index caf97183cd..d1c04dd86c 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -14,7 +14,7 @@ export { Model } from "@opencode-ai/schema/model" export { Permission } from "@opencode-ai/schema/permission" export { PermissionSaved } from "@opencode-ai/schema/permission-saved" export { Project } from "@opencode-ai/schema/project" -export { ProjectCopy } from "@opencode-ai/schema/project-copy" +export { Worktree } from "@opencode-ai/schema/worktree" export { Prompt } from "@opencode-ai/schema/prompt" export { PromptInput } from "@opencode-ai/schema/prompt-input" export { Provider } from "@opencode-ai/schema/provider" diff --git a/packages/sdk-next/test/contract-identity.test.ts b/packages/sdk-next/test/contract-identity.test.ts index 1e3c69a6c9..8eafed2d72 100644 --- a/packages/sdk-next/test/contract-identity.test.ts +++ b/packages/sdk-next/test/contract-identity.test.ts @@ -13,6 +13,7 @@ import { Session } from "@opencode-ai/schema/session" import { SessionInbox } from "@opencode-ai/schema/session-inbox" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Workspace } from "@opencode-ai/schema/workspace" +import { Worktree } from "@opencode-ai/schema/worktree" import { Api } from "@opencode-ai/server/api" import { ClientApi, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" @@ -22,6 +23,7 @@ const CoreAgent = await import("@opencode-ai/core/agent") const CoreModel = await import("@opencode-ai/core/model") const CoreProject = await import("@opencode-ai/core/project") const CoreSession = await import("@opencode-ai/core/session") +const CoreWorktree = await import("@opencode-ai/core/worktree") test("re-exports canonical contracts directly from Schema", () => { expect(SDK.Agent).toBe(Agent) @@ -29,6 +31,7 @@ test("re-exports canonical contracts directly from Schema", () => { expect(SDK.Model).toBe(Model) expect(SDK.WebSearch).toBe(WebSearch) expect(SDK.Session).toBe(Session) + expect(SDK.Worktree).toBe(Worktree) expect(SDK.Workspace).toBe(Workspace) expect(Object.keys(SDK).sort()).toEqual([ "AbsolutePath", @@ -45,7 +48,6 @@ test("re-exports canonical contracts directly from Schema", () => { "Permission", "PermissionSaved", "Project", - "ProjectCopy", "Prompt", "PromptInput", "Provider", @@ -60,6 +62,7 @@ test("re-exports canonical contracts directly from Schema", () => { "Tool", "WebSearch", "Workspace", + "Worktree", ]) }) @@ -69,8 +72,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(CoreModel.Ref).toBe(Model.Ref) expect(CoreSession.Info).toBe(Session.Info) expect(CoreProject.Current).toBe(Project.Current) - expect(CoreProject.Directory).toBe(Project.Directory) - expect(CoreProject.Directories).toBe(Project.Directories) + expect(CoreWorktree.DirectoryUnavailableError).toBeDefined() + expect(CoreWorktree.List).toBe(Worktree.List) + expect(CoreWorktree.Info).toBe(Worktree.Info) expect(CoreSessionInbox.Item).toBe(SessionInbox.Item) expect(CoreSessionInbox.User).toBe(SessionInbox.User) expect(CoreSessionInbox.Synthetic).toBe(SessionInbox.Synthetic) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 5e20da6f00..d5873410db 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -16,14 +16,14 @@ import { isRecord } from "../util/record" import { useToast } from "../ui/toast" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" -import type { ProjectDirectoriesOutput } from "@opencode-ai/client" +import type { WorktreeListOutput } from "@opencode-ai/client" import { useRoute } from "../context/route" -import { DialogProjectCopyName } from "./dialog-project-copy-name" +import { DialogWorktreeName } from "./dialog-worktree-name" export type MoveSessionSelection = | { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } -type ProjectDirectory = ProjectDirectoriesOutput[number] +type ProjectDirectory = WorktreeListOutput[number] type DialogMoveSessionProps = { projectID: string @@ -78,15 +78,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { () => (props.initialRemoving ? undefined : props.projectID), async (projectID, info): Promise | undefined> => { try { - const requestLocation = { directory: location()?.directory || paths.cwd } - await client.api.projectCopy.refresh({ - projectID, - location: requestLocation, - }) - const directories = await client.api.project.directories({ - projectID, - location: requestLocation, - }) + await client.api.worktree.refresh({ projectID }) + const directories = await client.api.worktree.list({ projectID }) setLoadError(undefined) return directories } catch (error) { @@ -234,10 +227,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { setToDelete(undefined) setRemoving(selected.directory) setWorking(true) - const error = await client.api.projectCopy + const error = await client.api.worktree .remove({ projectID: props.projectID, - location: { directory: location()?.directory || paths.cwd }, directory: selected.directory, force: false, }) @@ -253,18 +245,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { .status({ location: { directory: selected.directory } }) .catch(() => undefined) const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], { - title: "Delete working copy?", - message: "This working copy has file changes. Do you want to delete it anyway?", + title: "Delete worktree?", + message: "This worktree has file changes. Do you want to delete it anyway?", }) if (choice !== "yes") { reopen() return } reopen(selected.directory) - const forcedError = await client.api.projectCopy + const forcedError = await client.api.worktree .remove({ projectID: props.projectID, - location: { directory: location()?.directory || paths.cwd }, directory: selected.directory, force: true, }) @@ -275,7 +266,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (forcedError) { toast.show({ variant: "error", - title: "Failed to delete project copy", + title: "Failed to delete worktree", message: errorMessage(forcedError), }) reopen() @@ -289,7 +280,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { } toast.show({ variant: "error", - title: "Failed to delete project copy", + title: "Failed to delete worktree", message: errorMessage(error), }) return @@ -301,7 +292,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { } async function create() { - const name = await DialogProjectCopyName.show(dialog) + const name = await DialogWorktreeName.show(dialog) if (name === null) return props.onSelect({ type: "new", name }) } diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-worktree-name.tsx similarity index 83% rename from packages/tui/src/component/dialog-project-copy-name.tsx rename to packages/tui/src/component/dialog-worktree-name.tsx index 033af9b410..3acefef48e 100644 --- a/packages/tui/src/component/dialog-project-copy-name.tsx +++ b/packages/tui/src/component/dialog-worktree-name.tsx @@ -6,7 +6,7 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "../ui/dialog" import { useConfig } from "../config" -export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { +export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) { const dialog = useDialog() const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() @@ -30,8 +30,8 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void priority: 1, commands: [ { - id: "dialog.project_copy.generate", - title: "Generate project copy name", + id: "dialog.worktree.generate", + title: "Generate worktree name", group: "Dialog", run: generate, }, @@ -50,7 +50,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void - Name project copy + Name worktree dialog.clear()}> esc @@ -63,7 +63,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void setInputTarget(value) }} onSubmit={confirm} - placeholder="Project copy name" + placeholder="Worktree name" placeholderColor={theme.text.subdued} textColor={theme.text.formfield.default} focusedTextColor={theme.text.formfield.default} @@ -74,17 +74,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void enter submit - {shortcuts.get("dialog.project_copy.generate")} generate one + {shortcuts.get("dialog.worktree.generate")} generate one ) } -DialogProjectCopyName.show = (dialog: DialogContext) => +DialogWorktreeName.show = (dialog: DialogContext) => new Promise((resolve) => { dialog.replace( - () => , + () => , () => resolve(null), ) }) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index af2f40bf79..e6a90c43ef 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1843,7 +1843,7 @@ export function Prompt(props: PromptProps) { - (new working copy) + (new worktree) diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 20570a8315..90aa0aae7b 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -23,17 +23,16 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const projectID = await resolveProjectID() if (!projectID) return setCreating(true) - setProgress("Creating copy") + setProgress("Creating worktree") try { - const result = await client.api.projectCopy.create({ + const result = await client.api.worktree.create({ projectID, - location: { directory: data.location.info()?.directory || paths.cwd }, - strategy: "git_worktree", + strategy: "git", directory: path.join(paths.worktree, projectID.slice(0, 6)), name, }) const directory = result.directory - if (!directory) throw new Error("No project copy directory returned") + if (!directory) throw new Error("No worktree directory returned") // Call a location-based route to initialize it before moving on. await client.api.location.get({ location: { directory } }) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index be007d182a..2864ef0b21 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -243,10 +243,10 @@ export const Definitions = { "dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), - "dialog.project_copy.generate": keybind("tab", "Generate project copy name"), - "dialog.move_session.new": keybind("ctrl+m", "New project copy"), - "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), - "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"), + "dialog.worktree.generate": keybind("tab", "Generate worktree name"), + "dialog.move_session.new": keybind("ctrl+m", "New worktree"), + "dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"), + "dialog.move_session.refresh": keybind("ctrl+r", "Refresh worktrees"), "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index dc15939930..42afff2cab 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -39,7 +39,7 @@ import { createSimpleContext } from "./helper" import { useClient } from "./client" import { nonEmptyToolContent } from "../util/tool-display" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" -import { ProjectDirectories } from "@opencode-ai/schema/project-directories" +import { Worktree } from "@opencode-ai/schema/worktree" import { createEffect, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" @@ -458,9 +458,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } break } - case "project.directory.resolved": { + case "worktree.resolved": { for (const [sessionID, info] of Object.entries(store.session.info)) { - const adopted = ProjectDirectories.adopt( + const adopted = Worktree.adopt( { projectID: info.projectID, directory: info.location.directory }, event.data, ) diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index c447beb935..034098178d 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -107,7 +107,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType