fix(tui): render move errors inline (#32241)

This commit is contained in:
Aiden Cline
2026-06-16 07:13:11 -04:00
committed by GitHub
parent 3a2ff11be8
commit 94652cfe4a
2 changed files with 83 additions and 55 deletions

View File

@@ -22,14 +22,16 @@ import { useRoute } from "../context/route"
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
type ProjectDirectory = ProjectDirectories[number] type ProjectDirectory = ProjectDirectories[number]
export function DialogMoveSession(props: { type DialogMoveSessionProps = {
projectID: string projectID: string
current?: MoveSessionSelection current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void onCurrentChange?: (selection: MoveSessionSelection) => void
initialDirectories?: ProjectDirectory[] initialDirectories?: ProjectDirectory[]
initialRemoving?: string initialRemoving?: string
}) { }
export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
@@ -43,69 +45,75 @@ export function DialogMoveSession(props: {
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving) const [removing, setRemoving] = createSignal(props.initialRemoving)
const [replacementCurrent, setReplacementCurrent] = createSignal<string>() const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
const [loadError, setLoadError] = createSignal<unknown>()
const deleteHint = useCommandShortcut("dialog.move_session.delete") const deleteHint = useCommandShortcut("dialog.move_session.delete")
onMount(() => dialog.setSize("xlarge"))
function reopen(initialRemoving?: string) { function reopen(initialRemoving?: string) {
dialog.replace(() => ( dialog.replace(() => (
<DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} /> <DialogMoveSession {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
)) ))
} }
// A failed current-checkout lookup only affects which row is highlighted, so
// swallow it and let the directory list render without a current marker.
const [loadedProject] = createResource( const [loadedProject] = createResource(
() => (projectContext.project() === props.projectID ? undefined : props.projectID), () => (projectContext.project() === props.projectID ? undefined : props.projectID),
async (projectID) => { (projectID) =>
const result = await sdk.client.project.current({}, { throwOnError: true }) sdk.client.project
return result.data?.id === projectID ? result.data.worktree : undefined .current({}, { throwOnError: true })
}, .then((result) => (result.data?.id === projectID ? result.data.worktree : undefined))
) .catch(() => undefined),
const currentCheckout = createMemo(() =>
projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(),
) )
const currentCheckout = createMemo(() => {
if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree
return loadedProject()
})
const [directories, { refetch }] = createResource( const [directories, { refetch }] = createResource(
() => (props.initialRemoving ? undefined : props.projectID), () => (props.initialRemoving ? undefined : props.projectID),
async (projectID) => { async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
setWorking(true)
try { try {
await sdk.client.v2.projectCopy.refresh( await sdk.client.v2.projectCopy.refresh(
{ projectID, location: { directory: sdk.directory } }, { projectID, location: { directory: sdk.directory } },
{ throwOnError: true }, { throwOnError: true },
) )
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
setLoadError(undefined)
return directories.data ?? [] return directories.data ?? []
} catch (error) { } catch (error) {
toast.show({ setLoadError(error)
variant: "error", // An initial load with no data surfaces the inline error view below. A
title: "Failed to load project directories", // failed refresh intentionally stays quiet and keeps the already-shown
message: errorMessage(error), // list interactive; reopening the dialog retries the load.
}) return info.value
return []
} finally {
setWorking(false)
} }
}, },
{ initialValue: props.initialDirectories },
) )
const directoryData = createMemo(() => directories() ?? props.initialDirectories)
// Show the locked error view only when we have nothing to display. A refresh
// that fails after the list rendered keeps the list and its actions.
const showError = createMemo(() => Boolean(loadError()) && !directoryData())
const currentDirectory = createMemo( const currentDirectory = createMemo(
() => replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()), () => replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()),
) )
const currentRoot = createMemo<ProjectDirectory | undefined>(() => { const currentRoot = createMemo<ProjectDirectory | undefined>(() => {
if (showError()) return
const directory = currentDirectory() const directory = currentDirectory()
if (!directory) return if (!directory) return
return ( return (
directories() directoryData()
?.filter((root) => contains(root.directory, directory)) ?.filter((root) => contains(root.directory, directory))
.toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory } .toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory }
) )
}) })
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => { const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
const data = directories() if (showError()) return []
const data = directoryData()
const current = currentRoot()?.directory const current = currentRoot()?.directory
if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }] if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }]
if (directories.error && !data && !current)
return [{ title: "Failed to load project directories", value: undefined }]
const roots = [...(data ?? [])] const roots = [...(data ?? [])]
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
roots.sort((a, b) => { roots.sort((a, b) => {
@@ -201,7 +209,7 @@ export function DialogMoveSession(props: {
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) { async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
const data = directories() const data = directoryData()
const selected = option.value const selected = option.value
const root = data?.find((item) => item.directory === selected.directory) const root = data?.find((item) => item.directory === selected.directory)
if (!root?.strategy) return if (!root?.strategy) return
@@ -271,10 +279,12 @@ export function DialogMoveSession(props: {
if (await removedCurrent(deletingCurrent)) return if (await removedCurrent(deletingCurrent)) return
} }
onMount(() => dialog.setSize("xlarge")) const fullHeight = createMemo(() =>
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
)
return ( return (
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}> <box minHeight={showError() ? 5 : fullHeight()}>
<DialogSelect <DialogSelect
title="Move session" title="Move session"
titleView={ titleView={
@@ -282,40 +292,55 @@ export function DialogMoveSession(props: {
<text fg={theme.text} attributes={TextAttributes.BOLD}> <text fg={theme.text} attributes={TextAttributes.BOLD}>
Move session Move session
</text> </text>
<Show when={working()}> <Show when={working() || directories.loading || loadedProject.loading}>
<Spinner /> <Spinner />
</Show> </Show>
</box> </box>
} }
renderFilter={!showError()}
options={options()} options={options()}
locked={directories.loading || loadedProject.loading || Boolean(removing())} emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
</box>
) : undefined
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
current={current()} current={current()}
onSelect={(option) => { onSelect={(option) => {
if (option.value) props.onSelect(option.value) if (option.value) props.onSelect(option.value)
}} }}
onMove={() => setToDelete(undefined)} onMove={() => setToDelete(undefined)}
actions={[ actions={
{ showError()
command: "dialog.move_session.new", ? []
title: "new", : [
onTrigger: () => props.onSelect({ type: "new" }), {
}, command: "dialog.move_session.new",
{ title: "new",
command: "dialog.move_session.delete", onTrigger: () => props.onSelect({ type: "new" }),
title: "delete", },
disabled: (option) => { {
const value = option?.value command: "dialog.move_session.delete",
if (!value || value.type !== "directory" || value.subdirectory) return true title: "delete",
return !directories()?.find((item) => item.directory === value.directory)?.strategy disabled: (option) => {
}, const value = option?.value
onTrigger: remove, if (!value || value.type !== "directory" || value.subdirectory) return true
}, return !directoryData()?.find((item) => item.directory === value.directory)?.strategy
{ },
command: "dialog.move_session.refresh", onTrigger: remove,
title: "refresh", },
onTrigger: () => void refetch(), {
}, command: "dialog.move_session.refresh",
]} title: "refresh",
onTrigger: () => void refetch(),
},
]
}
/> />
</box> </box>
) )

View File

@@ -25,6 +25,7 @@ export interface DialogSelectProps<T> {
titleView?: JSX.Element titleView?: JSX.Element
placeholder?: string placeholder?: string
footer?: JSX.Element footer?: JSX.Element
emptyView?: JSX.Element
options: DialogSelectOption<T>[] options: DialogSelectOption<T>[]
flat?: boolean flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void ref?: (ref: DialogSelectRef<T>) => void
@@ -527,9 +528,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show <Show
when={grouped().length > 0} when={grouped().length > 0}
fallback={ fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}> props.emptyView ?? (
<text fg={theme.textMuted}>No results found</text> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
</box> <text fg={theme.textMuted}>No results found</text>
</box>
)
} }
> >
<scrollbox <scrollbox