refactor(tui): migrate worktree workflows

This commit is contained in:
James Long
2026-08-12 21:57:30 +00:00
parent eb75637742
commit b79e75e01a
9 changed files with 47 additions and 47 deletions
+1 -1
View File
@@ -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"
@@ -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)
@@ -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<ReadonlyArray<ProjectDirectory> | 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 })
}
@@ -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
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Name project copy
Name worktree
</text>
<text fg={theme.text.subdued} onMouseUp={() => 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 <span style={{ fg: theme.text.subdued }}>submit</span>
</text>
<text fg={theme.text.default}>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
{shortcuts.get("dialog.worktree.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
</text>
</box>
</box>
)
}
DialogProjectCopyName.show = (dialog: DialogContext) =>
DialogWorktreeName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => {
dialog.replace(
() => <DialogProjectCopyName onConfirm={resolve} />,
() => <DialogWorktreeName onConfirm={resolve} />,
() => resolve(null),
)
})
+1 -1
View File
@@ -1843,7 +1843,7 @@ export function Prompt(props: PromptProps) {
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
(new worktree)
</text>
</box>
</Match>
+4 -5
View File
@@ -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 } })
+4 -4
View File
@@ -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"),
+3 -3
View File
@@ -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,
)
+7 -1
View File
@@ -107,7 +107,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project") return json([])
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
if (url.pathname === "/api/experimental/project/proj_test/worktree") {
if (request.method === "GET") return json([{ directory: worktree }])
if (request.method === "POST") return json({ directory: `${worktree}/created` })
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/project/proj_test/worktree/refresh")
return new Response(null, { status: 204 })
if (url.pathname === "/api/shell")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },