fix(tui): render move errors inline (#32241)
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user