Skip to content

Commit 3c4b4d5

Browse files
authored
feat(core): copy file changes when warping (#26190)
1 parent b6ff1b1 commit 3c4b4d5

23 files changed

Lines changed: 955 additions & 28 deletions

packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { Flag } from "@opencode-ai/core/flag/flag"
1212
import { DialogSessionRename } from "./dialog-session-rename"
1313
import { createDebouncedSignal } from "../util/signal"
1414
import { useToast } from "../ui/toast"
15-
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
15+
import {
16+
openWorkspaceSelect,
17+
type WorkspaceSelection,
18+
warpWorkspaceSession,
19+
} from "./dialog-workspace-create"
1620
import { Spinner } from "./spinner"
1721
import { errorMessage } from "@/util/error"
1822
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
@@ -70,8 +74,10 @@ export function DialogSessionList() {
7074
sync,
7175
project,
7276
toast,
77+
sourceWorkspaceID: session.workspaceID,
7378
workspaceID,
7479
sessionID: session.id,
80+
copyChanges: false,
7581
done: list,
7682
})
7783
}

packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import { useDialog } from "@tui/ui/dialog"
33
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
44
import { useSync } from "@tui/context/sync"
55
import { useProject } from "@tui/context/project"
6+
import { useRoute } from "@tui/context/route"
67
import { createMemo, createSignal, onMount } from "solid-js"
78
import { errorMessage } from "@/util/error"
89
import { useSDK } from "../context/sdk"
910
import { useToast } from "../ui/toast"
11+
import { DialogAlert } from "../ui/dialog-alert"
12+
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
1013

1114
type Adapter = {
1215
type: string
@@ -38,13 +41,15 @@ export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(
3841
get: (workspaceID: string) => WorkspaceInfo | undefined
3942
status: (workspaceID: string) => string | undefined
4043
limit?: number
44+
omitWorkspaceID?: string
4145
}) {
4246
const workspaces = input.sessions
4347
.toSorted((a, b) => b.time.updated - a.time.updated)
4448
.flatMap((session) => {
4549
const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
4650
return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
4751
})
52+
.filter((workspace) => workspace.id !== input.omitWorkspaceID)
4853
.filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
4954
const recent = workspaces.slice(0, input.limit ?? 3)
5055

@@ -93,17 +98,29 @@ export async function warpWorkspaceSession(input: {
9398
sync: ReturnType<typeof useSync>
9499
project: ReturnType<typeof useProject>
95100
toast: ReturnType<typeof useToast>
101+
sourceWorkspaceID?: string
96102
workspaceID: string | null
97103
sessionID: string
104+
copyChanges: boolean
98105
done?: () => void
99106
}): Promise<boolean> {
100107
const result = await input.sdk.client.experimental.workspace
101108
.warp({
102109
id: input.workspaceID,
103110
sessionID: input.sessionID,
111+
copyChanges: input.copyChanges,
104112
})
105113
.catch(() => undefined)
106114
if (!result?.data) {
115+
if (result?.error?.name === "VcsApplyError") {
116+
await DialogAlert.show(
117+
input.dialog,
118+
"Unable to Warp Session",
119+
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
120+
)
121+
return false
122+
}
123+
107124
input.toast.show({
108125
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
109126
variant: "error",
@@ -143,16 +160,29 @@ export async function warpWorkspaceSession(input: {
143160
return true
144161
}
145162

163+
export async function confirmWorkspaceFileChanges(input: {
164+
dialog: ReturnType<typeof useDialog>
165+
sdk: ReturnType<typeof useSDK>
166+
sourceWorkspaceID?: string
167+
}) {
168+
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
169+
const fileChangeChoice = status?.data?.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no"
170+
if (!fileChangeChoice) return
171+
return fileChangeChoice === "yes"
172+
}
173+
146174
export function DialogWorkspaceSelect(props: {
147175
adapters?: Adapter[]
148176
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
149177
}) {
150178
const dialog = useDialog()
151179
const project = useProject()
180+
const route = useRoute()
152181
const sync = useSync()
153182
const sdk = useSDK()
154183
const toast = useToast()
155184
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
185+
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
156186

157187
onMount(() => {
158188
dialog.setSize("medium")
@@ -171,6 +201,7 @@ export function DialogWorkspaceSelect(props: {
171201
sessions: sync.data.session,
172202
get: project.workspace.get,
173203
status: project.workspace.status,
204+
omitWorkspaceID: omittedWorkspaceID(),
174205
})
175206
return [
176207
...list.map((adapter) => ({
@@ -231,19 +262,23 @@ export function DialogWorkspaceSelect(props: {
231262
return
232263
}
233264

234-
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
265+
dialog.replace(() => <DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />)
235266
}}
236267
/>
237268
)
238269
}
239270

240-
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
271+
function DialogExistingWorkspaceSelect(props: {
272+
omitWorkspaceID?: string
273+
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
274+
}) {
241275
const project = useProject()
242276

243277
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
244278
project.workspace
245279
.list()
246280
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
281+
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
247282
.map((workspace: Workspace) => ({
248283
title: workspace.name,
249284
description: `(${workspace.type})`,
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { TextAttributes } from "@opentui/core"
2+
import { useKeyboard } from "@opentui/solid"
3+
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
4+
import { createMemo, For } from "solid-js"
5+
import { createStore } from "solid-js/store"
6+
import { Locale } from "@/util/locale"
7+
import { useTheme } from "../context/theme"
8+
import { useTuiConfig } from "../context/tui-config"
9+
import { useDialog, type DialogContext } from "../ui/dialog"
10+
import { getScrollAcceleration } from "../util/scroll"
11+
12+
const options = ["no", "yes"] as const
13+
14+
export type WorkspaceFileChangesChoice = (typeof options)[number]
15+
16+
function statusLabel(status: VcsFileStatus["status"]) {
17+
if (status === "added") return "A"
18+
if (status === "deleted") return "D"
19+
return "M"
20+
}
21+
22+
function changeCountWidth(file: VcsFileStatus) {
23+
// The "plus 2" is for spaces
24+
return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
25+
}
26+
27+
export function DialogWorkspaceFileChanges(props: {
28+
files: VcsFileStatus[]
29+
onSelect: (choice: WorkspaceFileChangesChoice) => void
30+
}) {
31+
const dialog = useDialog()
32+
const { theme } = useTheme()
33+
const tuiConfig = useTuiConfig()
34+
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
35+
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
36+
const height = createMemo(() => Math.min(props.files.length, 8))
37+
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
38+
39+
function confirm() {
40+
props.onSelect(store.active)
41+
dialog.clear()
42+
}
43+
44+
useKeyboard((evt) => {
45+
if (evt.name === "return") {
46+
evt.preventDefault()
47+
evt.stopPropagation()
48+
confirm()
49+
return
50+
}
51+
if (evt.name === "left") {
52+
evt.preventDefault()
53+
evt.stopPropagation()
54+
const index = options.indexOf(store.active)
55+
setStore("active", options[Math.max(index - 1, 0)])
56+
return
57+
}
58+
if (evt.name === "right") {
59+
evt.preventDefault()
60+
evt.stopPropagation()
61+
const index = options.indexOf(store.active)
62+
setStore("active", options[Math.min(index + 1, options.length - 1)])
63+
}
64+
})
65+
66+
return (
67+
<box gap={1}>
68+
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
69+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
70+
File Changes Found
71+
</text>
72+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
73+
esc
74+
</text>
75+
</box>
76+
<scrollbox
77+
height={height()}
78+
backgroundColor={theme.backgroundElement}
79+
scrollbarOptions={{ visible: false }}
80+
scrollAcceleration={scrollAcceleration()}
81+
>
82+
<For each={props.files}>
83+
{(item) => (
84+
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
85+
<box flexDirection="row" minWidth={0} flexShrink={1}>
86+
<box width={2} flexShrink={0}>
87+
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
88+
</box>
89+
<text fg={theme.textMuted} wrapMode="none">
90+
{Locale.truncateLeft(item.file, fileNameWidth())}
91+
</text>
92+
</box>
93+
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
94+
<text>
95+
{" "}
96+
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
97+
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
98+
</text>
99+
</box>
100+
</box>
101+
)}
102+
</For>
103+
</scrollbox>
104+
<box paddingLeft={2} paddingRight={2}>
105+
<text fg={theme.textMuted} wrapMode="word">
106+
Do you want to apply these changes after warping?
107+
</text>
108+
</box>
109+
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
110+
<For each={options}>
111+
{(item) => (
112+
<box
113+
paddingLeft={2}
114+
paddingRight={2}
115+
backgroundColor={item === store.active ? theme.primary : undefined}
116+
onMouseUp={() => {
117+
setStore("active", item)
118+
props.onSelect(item)
119+
dialog.clear()
120+
}}
121+
>
122+
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
123+
</box>
124+
)}
125+
</For>
126+
</box>
127+
</box>
128+
)
129+
}
130+
131+
DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
132+
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
133+
dialog.replace(
134+
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
135+
() => resolve(undefined),
136+
)
137+
})
138+
}

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ import { useKV } from "../../context/kv"
4242
import { createFadeIn } from "../../util/signal"
4343
import { useTextareaKeybindings } from "../textarea-keybindings"
4444
import { DialogSkill } from "../dialog-skill"
45-
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
45+
import {
46+
confirmWorkspaceFileChanges,
47+
openWorkspaceSelect,
48+
warpWorkspaceSession,
49+
type WorkspaceSelection,
50+
} from "../dialog-workspace-create"
4651
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
4752
import { useArgs } from "@tui/context/args"
4853
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -230,6 +235,9 @@ export function Prompt(props: PromptProps) {
230235
if (selection.type === "new") void createWorkspace(selection)
231236
return
232237
}
238+
const sourceWorkspaceID = project.workspace.current()
239+
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
240+
if (copyChanges === undefined) return
233241
selectWorkspace(selection)
234242
dialog.clear()
235243

@@ -247,8 +255,10 @@ export function Prompt(props: PromptProps) {
247255
sync,
248256
project,
249257
toast,
258+
sourceWorkspaceID,
250259
workspaceID: workspace.id,
251260
sessionID: props.sessionID,
261+
copyChanges,
252262
})
253263
if (warped) showWarpNotice(workspace.name)
254264
}

0 commit comments

Comments
 (0)