feat(tui): delete working copies from move dialog (#31017)

This commit is contained in:
James Long
2026-06-05 16:47:10 -04:00
committed by GitHub
parent 025e1ac69f
commit f591bf5f93
19 changed files with 337 additions and 117 deletions
@@ -157,7 +157,7 @@ export function DialogModel(props: { providerID?: string }) {
{
command: "model.dialog.favorite",
title: "Favorite",
disabled: !connected(),
hidden: !connected(),
onTrigger: (option) => {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
@@ -1,56 +1,82 @@
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useTheme } from "@tui/context/theme"
import { useKV } from "@tui/context/kv"
import { useSync } from "@tui/context/sync"
import { Global } from "@opencode-ai/core/global"
import { Locale } from "@/util/locale"
import "opentui-spinner/solid"
import { errorMessage } from "@/util/error"
import { useToast } from "@tui/ui/toast"
import { useCommandShortcut } from "@tui/keymap"
import { useProject } from "@tui/context/project"
import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
const REFRESH_FRAMES = ["■", "⬝"]
export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
export type MoveSessionSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
| { type: "new" }
export function DialogMoveSession(props: {
projectID: string
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
initialDirectories?: string[]
initialRemoving?: string
}) {
const dialog = useDialog()
const sdk = useSDK()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const kv = useKV()
const sync = useSync()
const [refreshing, setRefreshing] = createSignal(false)
const projectContext = useProject()
const toast = useToast()
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
const deleteHint = useCommandShortcut("dialog.move_session.delete")
const [directories] = createResource(
() => props.projectID,
function reopen(initialRemoving?: string) {
dialog.replace(() => (
<DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} />
))
}
const [loadedProject] = createResource(
() => (projectContext.project() === props.projectID ? undefined : props.projectID),
async (projectID) => {
setRefreshing(true)
const [, project] = await Promise.all([
sdk.client.experimental.projectCopy
.refresh({ projectID }, { throwOnError: true })
.finally(() => setRefreshing(false)),
sdk.client.project.current({}, { throwOnError: true }),
])
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
return {
directories: directories.data ?? [],
main: project.data?.id === projectID ? project.data.worktree : undefined,
const result = await sdk.client.project.current({}, { throwOnError: true })
return result.data?.id === projectID ? result.data.worktree : undefined
},
)
const project = createMemo(() =>
projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
)
const [directories, { refetch }] = createResource(
() => (props.initialRemoving ? undefined : props.projectID),
async (projectID) => {
setWorking(true)
try {
await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true })
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
return directories.data ?? []
} finally {
setWorking(false)
}
},
{ initialValue: props.initialDirectories },
)
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
if (directories.loading) return [{ title: "Loading project directories...", value: undefined }]
if (directories.error) return [{ title: "Failed to load project directories", value: undefined }]
const data = directories()
const roots = data ? [...new Set(data.main ? [data.main, ...data.directories] : data.directories)] : []
const main = project()
if (directories.loading && !data && !main) return [{ title: "Loading project directories...", value: undefined }]
if (directories.error && !data && !main) return [{ title: "Failed to load project directories", value: undefined }]
const roots = [...new Set(main ? [main, ...(data ?? [])] : (data ?? []))]
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
const subdirectories = sync.data.session
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
@@ -84,50 +110,135 @@ export function DialogMoveSession(props: {
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
const visible = Locale.truncateLeft(title, titleWidth)
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
const deleting = toDelete() === item.location
const isRemoving = removing() === item.location
return {
title,
titleView: suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
</>
) : undefined,
value: { type: "directory", directory: item.location } as const,
category: item.root === data?.main ? "Project" : "Working copies",
title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
titleView:
isRemoving ? (
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
) : !deleting && suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? theme.error : undefined,
value: { type: "directory", directory: item.location, subdirectory: item.location !== item.root } as const,
category: item.root === main ? "Project" : "Working copies",
titleWidth,
truncateTitle: "left" as const,
}
})
})
const current = createMemo(() => {
if (directories.loading || loadedProject.loading || !props.current) return
if (props.current.type === "new") return props.current
const directory = props.current.directory
return options().find(
(option) => option.value?.type === "directory" && option.value.directory === directory,
)?.value
})
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
const data = directories()
const main = project()
if (!data || !main || option.value.directory === main || !data.includes(option.value.directory)) return
if (toDelete() !== option.value.directory) {
setToDelete(option.value.directory)
return
}
setToDelete(undefined)
setRemoving(option.value.directory)
setWorking(true)
const result = await sdk.client.experimental.projectCopy
.remove({ projectID: props.projectID, directory: option.value.directory, force: false })
.catch((error) => ({ error }))
if (result.error) {
setRemoving(undefined)
setWorking(false)
if ("data" in result.error && result.error.data.forceRequired) {
const status = await sdk.client.vcs.status({ directory: option.value.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?",
})
if (choice !== "yes") {
reopen()
return
}
reopen(option.value.directory)
const forced = await sdk.client.experimental.projectCopy
.remove({ projectID: props.projectID, directory: option.value.directory, force: true })
.catch((error) => ({ error }))
if (forced.error) {
toast.show({
variant: "error",
title: "Failed to delete project copy",
message: errorMessage(forced.error),
})
}
reopen()
return
}
toast.show({
variant: "error",
title: "Failed to delete project copy",
message: errorMessage(result.error),
})
return
}
await refetch()
setRemoving(undefined)
}
onMount(() => dialog.setSize("xlarge"))
return (
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
<DialogSelect
title="Move session"
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Move session
</text>
<Show when={working()}>
<Spinner />
</Show>
</box>
}
options={options()}
current={props.current}
locked={directories.loading || loadedProject.loading || Boolean(removing())}
current={current()}
onSelect={(option) => {
if (option.value) props.onSelect(option.value)
}}
onMove={() => setToDelete(undefined)}
actions={[
{
command: "dialog.move_session.new",
title: "new",
onTrigger: () => props.onSelect({ type: "new" }),
},
{
command: "dialog.move_session.delete",
title: "delete",
disabled: (option) =>
!option?.value ||
option.value.type !== "directory" ||
option.value.subdirectory ||
option.value.directory === project(),
onTrigger: remove,
},
{
command: "dialog.move_session.refresh",
title: "refresh",
onTrigger: () => void refetch(),
},
]}
footer={
<Show when={refreshing()}>
<box flexDirection="row" gap={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}></text>}>
<spinner color={theme.textMuted} frames={REFRESH_FRAMES} interval={160} />
</Show>
<text fg={theme.textMuted}>refreshing</text>
</box>
</Show>
}
/>
</box>
)
@@ -27,6 +27,8 @@ function changeCountWidth(file: VcsFileStatus) {
export function DialogWorkspaceFileChanges(props: {
files: VcsFileStatus[]
onSelect: (choice: WorkspaceFileChangesChoice) => void
title?: string
message?: string
}) {
const dialog = useDialog()
const { theme } = useTheme()
@@ -67,12 +69,17 @@ export function DialogWorkspaceFileChanges(props: {
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
File Changes Found
{props.title ?? "File Changes Found"}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
<scrollbox
height={height()}
backgroundColor={theme.backgroundElement}
@@ -101,11 +108,6 @@ export function DialogWorkspaceFileChanges(props: {
)}
</For>
</scrollbox>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
Do you want to move these changes with the session?
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
<For each={options}>
{(item) => (
@@ -128,10 +130,14 @@ export function DialogWorkspaceFileChanges(props: {
)
}
DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
DialogWorkspaceFileChanges.show = (
dialog: DialogContext,
files: VcsFileStatus[],
options?: { title?: string; message?: string },
) => {
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
dialog.replace(
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} {...options} />,
() => resolve(undefined),
)
})
@@ -57,7 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
<DialogMoveSession
projectID={projectID}
current={
homeDestination?.destination() ?? (session ? { type: "directory", directory: session.directory } : undefined)
homeDestination?.destination() ??
(session
? {
type: "directory",
directory: session.directory,
subdirectory: !!session.path,
}
: undefined)
}
onSelect={(selection) => {
const sessionID = input.sessionID()
@@ -208,6 +208,8 @@ export const Definitions = {
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"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"),
"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"),
@@ -22,6 +22,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
worktree: undefined as string | undefined,
},
instance: {
path: defaultPath,
@@ -43,6 +44,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
batch(() => {
setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id)
setStore("project", "worktree", project.data?.worktree)
})
}
@@ -205,19 +205,19 @@ function View(props: { api: TuiPluginApi }) {
current={cur()}
onMove={(item) => setCur(item.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
disabled: lock(),
{
title: "toggle",
command: "plugins.toggle",
hidden: lock(),
onTrigger: (item) => {
setCur(item.value)
flip(item.value)
},
},
{
title: "install",
command: "dialog.plugins.install",
disabled: lock(),
{
title: "install",
command: "dialog.plugins.install",
hidden: lock(),
onTrigger: () => {
showInstall(props.api)
},
@@ -9,7 +9,9 @@ import {
} from "solid-js"
import { useSync } from "../../context/sync"
export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" }
export type HomeSessionDestination =
| { type: "directory"; directory: string; subdirectory: boolean }
| { type: "new" }
type Context = {
destination: Accessor<HomeSessionDestination | undefined>
@@ -22,8 +24,8 @@ const HomeSessionDestinationContext = createContext<Context>()
export function HomeSessionDestinationProvider(props: ParentProps) {
const sync = useSync()
const [selected, setDestination] = createSignal<HomeSessionDestination>()
const destination = createMemo<HomeSessionDestination>(
() => selected() ?? { type: "directory", directory: sync.path.directory || process.cwd() },
const destination = createMemo<HomeSessionDestination>(() =>
selected() ?? { type: "directory", directory: sync.path.directory || process.cwd(), subdirectory: false },
)
return (
<HomeSessionDestinationContext.Provider
@@ -22,6 +22,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
title: string
titleView?: JSX.Element
placeholder?: string
footer?: JSX.Element
options: DialogSelectOption<T>[]
@@ -32,11 +33,13 @@ export interface DialogSelectProps<T> {
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
locked?: boolean
actions?: {
command: string
title: string
side?: "left" | "right"
disabled?: boolean
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
onTrigger: (option: DialogSelectOption<T>) => void
}[]
footerHints?: {
@@ -108,17 +111,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: actions().map((item) => item.command),
commands: shownActions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of actions()) {
for (const action of shownActions()) {
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
if (label) labels.set(action.command, label)
}
@@ -126,12 +130,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return labels
})
const visibleActions = createMemo(() => [
...actions()
...shownActions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => !item.disabled && item.label),
.filter((item) => item.label),
...(props.footerHints ?? []),
])
const actionItems = createMemo(() => visibleActions().filter(isActionItem))
const actionItems = createMemo(() => visibleActions().filter(isActionItem).filter((item) => !isActionDisabled(item)))
createEffect(() => {
const index = focusedAction()
@@ -217,6 +221,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)
function move(direction: number) {
if (props.locked) return
if (flat().length === 0) return
let next = store.selected + direction
if (next < 0) next = flat().length - 1
@@ -252,6 +257,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
function submit() {
if (props.locked) return
setStore("input", "keyboard")
const index = focusedAction()
if (index !== undefined) {
@@ -265,6 +271,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
function moveAction(direction: 1 | -1) {
if (props.locked) return
const total = actionItems().length
if (total === 0) return
setFocusedAction((index) => {
@@ -275,7 +282,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
useBindings(() => {
const enabledActions = actions().filter((item) => !item.disabled)
const visible = shownActions()
return {
commands: [
@@ -320,6 +327,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
title: "First item",
category: "Dialog",
run() {
if (props.locked) return
setStore("input", "keyboard")
moveTo(0)
},
@@ -329,6 +337,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
title: "Last item",
category: "Dialog",
run() {
if (props.locked) return
setStore("input", "keyboard")
moveTo(flat().length - 1)
},
@@ -339,11 +348,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
category: "Dialog",
run: submit,
},
...enabledActions.map((item) => ({
...visible.map((item) => ({
name: item.command,
title: item.title,
category: "Dialog",
run() {
if (props.locked) return
if (isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
@@ -361,8 +372,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
"dialog.select.end",
"dialog.select.submit",
]),
...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
...(enabledActions.length
...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)),
...(visible.length
? [
{
key: "tab",
@@ -380,7 +391,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
: []),
...(props.bindings ?? []).filter((binding) => {
if (typeof binding.cmd !== "string") return true
return enabledActions.some((item) => item.command === binding.cmd)
return visible.some((item) => item.command === binding.cmd)
}),
],
}
@@ -408,7 +419,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
function triggerAction(item: VisibleAction | undefined) {
if (!item || !isActionItem(item)) return
if (props.locked) return
if (!item || !isActionItem(item) || isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
@@ -419,7 +431,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return "onTrigger" in item
}
function isActionDisabled(item: Action) {
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
}
function isActionFocused(item: VisibleAction) {
if (props.locked) return false
if (!isActionItem(item)) return false
return actionItems().indexOf(item) === focusedAction()
}
@@ -434,19 +451,24 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<span style={{ fg: theme.textMuted }}>{action.item.label}</span>
</text>
)
const active = createMemo(() => isActionFocused(action.item))
const item = action.item
const active = createMemo(() => isActionFocused(item))
const disabled = createMemo(() => isActionDisabled(item))
const fg = selectedForeground(theme)
return (
<box
flexDirection="row"
paddingRight={1}
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => triggerAction(action.item)}
onMouseUp={() => triggerAction(item)}
>
<text fg={active() ? fg : theme.text} attributes={active() ? TextAttributes.BOLD : undefined}>
{action.item.title}
<text
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
attributes={active() ? TextAttributes.BOLD : undefined}
>
{item.title}
</text>
<text fg={active() ? fg : theme.textMuted}> {action.item.label}</text>
<text fg={disabled() ? theme.textMuted : active() ? fg : theme.textMuted}> {item.label}</text>
</box>
)
}
@@ -455,9 +477,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box gap={1} paddingBottom={1} flexGrow={1}>
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
</text>
{props.titleView ?? (
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
</text>
)}
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
@@ -466,6 +490,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box paddingTop={1}>
<input
onInput={(e) => {
if (props.locked) return
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
@@ -525,7 +550,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const active = createMemo(() => !props.locked && isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
<box
@@ -533,20 +558,24 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
flexDirection="column"
position="relative"
onMouseMove={() => {
if (props.locked) return
setStore("input", "mouse")
setFocusedAction(undefined)
}}
onMouseUp={() => {
if (props.locked) return
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
if (props.locked) return
if (store.input !== "mouse") return
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
onMouseDown={() => {
if (props.locked) return
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)