Skip to content

Commit e0c2461

Browse files
authored
fix(tui): render move errors inline (anomalyco#32241)
1 parent 3cf9d2e commit e0c2461

2 files changed

Lines changed: 83 additions & 55 deletions

File tree

packages/tui/src/component/dialog-move-session.tsx

Lines changed: 77 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@ import { useRoute } from "../context/route"
2222
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
2323
type ProjectDirectory = ProjectDirectories[number]
2424

25-
export function DialogMoveSession(props: {
25+
type DialogMoveSessionProps = {
2626
projectID: string
2727
current?: MoveSessionSelection
2828
onSelect: (selection: MoveSessionSelection) => void
2929
onCurrentChange?: (selection: MoveSessionSelection) => void
3030
initialDirectories?: ProjectDirectory[]
3131
initialRemoving?: string
32-
}) {
32+
}
33+
34+
export function DialogMoveSession(props: DialogMoveSessionProps) {
3335
const dialog = useDialog()
3436
const sdk = useSDK()
3537
const dimensions = useTerminalDimensions()
@@ -43,69 +45,75 @@ export function DialogMoveSession(props: {
4345
const [toDelete, setToDelete] = createSignal<string>()
4446
const [removing, setRemoving] = createSignal(props.initialRemoving)
4547
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
48+
const [loadError, setLoadError] = createSignal<unknown>()
4649
const deleteHint = useCommandShortcut("dialog.move_session.delete")
50+
onMount(() => dialog.setSize("xlarge"))
4751

4852
function reopen(initialRemoving?: string) {
4953
dialog.replace(() => (
50-
<DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} />
54+
<DialogMoveSession {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
5155
))
5256
}
5357

58+
// A failed current-checkout lookup only affects which row is highlighted, so
59+
// swallow it and let the directory list render without a current marker.
5460
const [loadedProject] = createResource(
5561
() => (projectContext.project() === props.projectID ? undefined : props.projectID),
56-
async (projectID) => {
57-
const result = await sdk.client.project.current({}, { throwOnError: true })
58-
return result.data?.id === projectID ? result.data.worktree : undefined
59-
},
60-
)
61-
const currentCheckout = createMemo(() =>
62-
projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(),
62+
(projectID) =>
63+
sdk.client.project
64+
.current({}, { throwOnError: true })
65+
.then((result) => (result.data?.id === projectID ? result.data.worktree : undefined))
66+
.catch(() => undefined),
6367
)
68+
const currentCheckout = createMemo(() => {
69+
if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree
70+
return loadedProject()
71+
})
6472

6573
const [directories, { refetch }] = createResource(
6674
() => (props.initialRemoving ? undefined : props.projectID),
67-
async (projectID) => {
68-
setWorking(true)
75+
async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
6976
try {
7077
await sdk.client.v2.projectCopy.refresh(
7178
{ projectID, location: { directory: sdk.directory } },
7279
{ throwOnError: true },
7380
)
7481
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
82+
setLoadError(undefined)
7583
return directories.data ?? []
7684
} catch (error) {
77-
toast.show({
78-
variant: "error",
79-
title: "Failed to load project directories",
80-
message: errorMessage(error),
81-
})
82-
return []
83-
} finally {
84-
setWorking(false)
85+
setLoadError(error)
86+
// An initial load with no data surfaces the inline error view below. A
87+
// failed refresh intentionally stays quiet and keeps the already-shown
88+
// list interactive; reopening the dialog retries the load.
89+
return info.value
8590
}
8691
},
87-
{ initialValue: props.initialDirectories },
8892
)
93+
const directoryData = createMemo(() => directories() ?? props.initialDirectories)
94+
// Show the locked error view only when we have nothing to display. A refresh
95+
// that fails after the list rendered keeps the list and its actions.
96+
const showError = createMemo(() => Boolean(loadError()) && !directoryData())
8997

9098
const currentDirectory = createMemo(
9199
() => replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()),
92100
)
93101
const currentRoot = createMemo<ProjectDirectory | undefined>(() => {
102+
if (showError()) return
94103
const directory = currentDirectory()
95104
if (!directory) return
96105
return (
97-
directories()
106+
directoryData()
98107
?.filter((root) => contains(root.directory, directory))
99108
.toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory }
100109
)
101110
})
102111

103112
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
104-
const data = directories()
113+
if (showError()) return []
114+
const data = directoryData()
105115
const current = currentRoot()?.directory
106116
if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }]
107-
if (directories.error && !data && !current)
108-
return [{ title: "Failed to load project directories", value: undefined }]
109117
const roots = [...(data ?? [])]
110118
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
111119
roots.sort((a, b) => {
@@ -201,7 +209,7 @@ export function DialogMoveSession(props: {
201209

202210
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
203211
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
204-
const data = directories()
212+
const data = directoryData()
205213
const selected = option.value
206214
const root = data?.find((item) => item.directory === selected.directory)
207215
if (!root?.strategy) return
@@ -271,51 +279,68 @@ export function DialogMoveSession(props: {
271279
if (await removedCurrent(deletingCurrent)) return
272280
}
273281

274-
onMount(() => dialog.setSize("xlarge"))
282+
const fullHeight = createMemo(() =>
283+
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
284+
)
275285

276286
return (
277-
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
287+
<box minHeight={showError() ? 5 : fullHeight()}>
278288
<DialogSelect
279289
title="Move session"
280290
titleView={
281291
<box flexDirection="row" gap={1}>
282292
<text fg={theme.text} attributes={TextAttributes.BOLD}>
283293
Move session
284294
</text>
285-
<Show when={working()}>
295+
<Show when={working() || directories.loading || loadedProject.loading}>
286296
<Spinner />
287297
</Show>
288298
</box>
289299
}
300+
renderFilter={!showError()}
290301
options={options()}
291-
locked={directories.loading || loadedProject.loading || Boolean(removing())}
302+
emptyView={
303+
showError() ? (
304+
<box paddingLeft={4} paddingRight={4}>
305+
<text fg={theme.error} attributes={TextAttributes.BOLD}>
306+
Could not load project directories
307+
</text>
308+
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
309+
</box>
310+
) : undefined
311+
}
312+
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
292313
current={current()}
293314
onSelect={(option) => {
294315
if (option.value) props.onSelect(option.value)
295316
}}
296317
onMove={() => setToDelete(undefined)}
297-
actions={[
298-
{
299-
command: "dialog.move_session.new",
300-
title: "new",
301-
onTrigger: () => props.onSelect({ type: "new" }),
302-
},
303-
{
304-
command: "dialog.move_session.delete",
305-
title: "delete",
306-
disabled: (option) => {
307-
const value = option?.value
308-
if (!value || value.type !== "directory" || value.subdirectory) return true
309-
return !directories()?.find((item) => item.directory === value.directory)?.strategy
310-
},
311-
onTrigger: remove,
312-
},
313-
{
314-
command: "dialog.move_session.refresh",
315-
title: "refresh",
316-
onTrigger: () => void refetch(),
317-
},
318-
]}
318+
actions={
319+
showError()
320+
? []
321+
: [
322+
{
323+
command: "dialog.move_session.new",
324+
title: "new",
325+
onTrigger: () => props.onSelect({ type: "new" }),
326+
},
327+
{
328+
command: "dialog.move_session.delete",
329+
title: "delete",
330+
disabled: (option) => {
331+
const value = option?.value
332+
if (!value || value.type !== "directory" || value.subdirectory) return true
333+
return !directoryData()?.find((item) => item.directory === value.directory)?.strategy
334+
},
335+
onTrigger: remove,
336+
},
337+
{
338+
command: "dialog.move_session.refresh",
339+
title: "refresh",
340+
onTrigger: () => void refetch(),
341+
},
342+
]
343+
}
319344
/>
320345
</box>
321346
)

packages/tui/src/ui/dialog-select.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface DialogSelectProps<T> {
2525
titleView?: JSX.Element
2626
placeholder?: string
2727
footer?: JSX.Element
28+
emptyView?: JSX.Element
2829
options: DialogSelectOption<T>[]
2930
flat?: boolean
3031
ref?: (ref: DialogSelectRef<T>) => void
@@ -527,9 +528,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
527528
<Show
528529
when={grouped().length > 0}
529530
fallback={
530-
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
531-
<text fg={theme.textMuted}>No results found</text>
532-
</box>
531+
props.emptyView ?? (
532+
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
533+
<text fg={theme.textMuted}>No results found</text>
534+
</box>
535+
)
533536
}
534537
>
535538
<scrollbox

0 commit comments

Comments
 (0)