refactor(tui): migrate worktree workflows
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
+8
-8
@@ -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),
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user