fix(tui): distinguish asynchronous UI states (#36759)

This commit is contained in:
Kit Langton 2026-07-14 10:56:12 -04:00 committed by GitHub
commit 26e27c7a7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 219 additions and 116 deletions

View file

@ -90,6 +90,11 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
<text fg={theme.textMuted}>No integrations available</text> <text fg={theme.textMuted}>No integrations available</text>
</box> </box>
} }
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No integrations found</text>
</box>
}
/> />
) )
} }
@ -183,8 +188,8 @@ function KeyMethod(props: {
placeholder="API key" placeholder="API key"
onConfirm={(key) => { onConfirm={(key) => {
if (!key) return if (!key) return
void client.api.integration void client.api.integration.connect
.connect.key({ .key({
integrationID: props.integration.id, integrationID: props.integration.id,
location: location(data), location: location(data),
key, key,
@ -222,8 +227,8 @@ function OAuthStarting(props: {
const toast = useToast() const toast = useToast()
onMount(() => { onMount(() => {
void client.api.integration void client.api.integration.connect
.connect.oauth({ .oauth({
integrationID: props.integration.id, integrationID: props.integration.id,
location: location(data), location: location(data),
methodID: props.method.id, methodID: props.method.id,
@ -291,8 +296,8 @@ function OAuthAuto(props: {
})) }))
const poll = () => { const poll = () => {
void client.api.integration void client.api.integration.attempt
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) .status({ attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => { .then((result) => {
const status = result.data const status = result.data
if (status.status === "pending") { if (status.status === "pending") {
@ -357,8 +362,8 @@ function OAuthCode(props: {
placeholder="Authorization code" placeholder="Authorization code"
onConfirm={(code) => { onConfirm={(code) => {
if (!code) return if (!code) return
void client.api.integration void client.api.integration.attempt
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .complete({ attemptID: props.attempt.attemptID, location: location(data), code })
.then(() => { .then(() => {
settled = true settled = true
return connected(props.integration, data, dialog, toast, props.onConnected) return connected(props.integration, data, dialog, toast, props.onConnected)

View file

@ -21,7 +21,9 @@ import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { DialogProjectCopyName } from "./dialog-project-copy-name" import { DialogProjectCopyName } from "./dialog-project-copy-name"
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } export type MoveSessionSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
| { type: "new"; name: string }
type ProjectDirectory = ProjectDirectoriesOutput[number] type ProjectDirectory = ProjectDirectoriesOutput[number]
type DialogMoveSessionProps = { type DialogMoveSessionProps = {
@ -120,7 +122,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (showError()) return [] if (showError()) return []
const data = directoryData() 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 []
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) => {
@ -130,13 +132,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
return 0 return 0
}) })
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }] if (roots.length === 0) return []
const subdirectories = sessionData.session const subdirectories = sessionData.session
.list() .list()
.filter( .filter(
(session) => (session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
) )
.map((session) => session.location.directory) .map((session) => session.location.directory)
.filter((directory) => !roots.some((root) => root.directory === directory)) .filter((directory) => !roots.some((root) => root.directory === directory))
@ -326,13 +327,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
options={options()} options={options()}
emptyView={ emptyView={
showError() ? ( showError() ? (
<box paddingLeft={4} paddingRight={4}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.error} attributes={TextAttributes.BOLD}> <text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load project directories Could not load project directories
</text> </text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text> <text fg={theme.textMuted}>{errorMessage(loadError())}</text>
<text fg={theme.textMuted}>Close and reopen Move session to try again.</text>
</box> </box>
) : undefined ) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No project directories found</text>
</box>
} }
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
current={current()} current={current()}

View file

@ -25,12 +25,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
dialog.setCentered(true) dialog.setCentered(true)
const [server] = createResource(() => const [server] = createResource(() =>
client.api.server client.api.server.get().catch((error) => {
.get() setLoadError(error)
.catch((error) => { return undefined
setLoadError(error) }),
return undefined
}),
) )
const info = createMemo(() => { const info = createMemo(() => {
const current = server() const current = server()
@ -46,11 +44,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const value = info() const value = info()
if (!value) return if (!value) return
return ( return (
<box <box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
flexDirection={horizontal() ? "row" : "column"}
alignItems={horizontal() ? "flex-start" : "center"}
gap={2}
>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}> <box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box> <box>
<text fg={theme.textMuted}>URLs</text> <text fg={theme.textMuted}>URLs</text>
@ -72,9 +66,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
{showPassword() ? value.password : "************"} {showPassword() ? value.password : "************"}
</text> </text>
</box> </box>
<Show <Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}
>
<text fg={theme.textMuted} wrapMode="word"> <text fg={theme.textMuted} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely. Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text> </text>
@ -102,23 +94,35 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
esc esc
</text> </text>
</box> </box>
<Show when={loadError()}> <Show
{(error) => <text fg={theme.error}>{errorMessage(error())}</text>} when={loadError()}
</Show> fallback={
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information...</text>}> <Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information</text>}>
<Show <Show
when={dimensions().height >= 36} when={dimensions().height >= 36}
fallback={ fallback={
<scrollbox <scrollbox
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)} height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
scrollbarOptions={{ visible: false }} scrollbarOptions={{ visible: false }}
>
{content()}
</scrollbox>
}
> >
{content()} {content()}
</scrollbox> </Show>
} </Show>
> }
{content()} >
</Show> {(error) => (
<box>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load server information
</text>
<text fg={theme.textMuted}>{errorMessage(error())}</text>
<text fg={theme.textMuted}>Close and reopen Pair to try again.</text>
</box>
)}
</Show> </Show>
</box> </box>
) )

View file

@ -26,6 +26,7 @@ export function DialogSessionList() {
const client = useClient() const client = useClient()
const local = useLocal() const local = useLocal()
const toast = useToast() const toast = useToast()
const [filter, setFilter] = createSignal("")
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
@ -44,21 +45,38 @@ export function DialogSessionList() {
directory: location.directory, directory: location.directory,
workspace: location.workspaceID, workspace: location.workspaceID,
}) })
return { query, sessions: response.data } return { query, sessions: response.data, error: undefined }
} catch (error) { } catch (error) {
// A transient transport failure must degrade search, not crash the TUI // A transient transport failure must degrade search, not crash the TUI
// through the root ErrorBoundary when the errored resource is read. // through the root ErrorBoundary when the errored resource is read.
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) return { query, sessions: [] as SessionInfo[], error }
return { query, sessions: [] as SessionInfo[] }
} }
}) })
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const localSessions = createMemo(() => {
const query = filter().trim().toLowerCase()
const sessions = data.session.list()
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
})
const sessions = createMemo(() => { const sessions = createMemo(() => {
const query = search() const query = filter()
if (!query) return data.session.list() const local = localSessions()
if (!query) return local
if (query !== search() || searchResults.loading) return local
const result = searchResults() const result = searchResults()
return result?.query === query ? result.sessions : [] if (result?.query !== query || result.error) return local
return result.sessions
})
const searchState = createMemo(() => {
const query = filter()
if (!query) return { message: "No sessions available", error: false }
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
const result = searchResults()
if (result?.query === query && result.error)
return { message: "Could not search sessions. Change the search to try again.", error: true }
return { message: "No sessions found", error: false }
}) })
const quickSwitchHint = createMemo(() => { const quickSwitchHint = createMemo(() => {
@ -120,7 +138,20 @@ export function DialogSessionList() {
options={options()} options={options()}
skipFilter={true} skipFilter={true}
current={currentSessionID()} current={currentSessionID()}
onFilter={setSearch} onFilter={(query) => {
setFilter(query)
setSearch(query)
}}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No sessions available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? theme.error : theme.textMuted}>{searchState().message}</text>
</box>
}
onMove={() => setToDelete(undefined)} onMove={() => setToDelete(undefined)}
onSelect={(option) => { onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value }) route.navigate({ type: "session", sessionID: option.value })

View file

@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { createResource, createMemo, createSignal } from "solid-js" import { createResource, createMemo, createSignal, Match, Switch } from "solid-js"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
@ -57,17 +57,36 @@ export function DialogSkill(props: DialogSkillProps) {
<DialogSelect <DialogSelect
title="Skills" title="Skills"
options={options()} options={options()}
renderFilter={!showError()} renderFilter={!showError() && !skills.loading}
locked={showError()} locked={showError() || skills.loading}
emptyView={ emptyView={
showError() ? ( <Switch
<box paddingLeft={4} paddingRight={4}> fallback={
<text fg={theme.error} attributes={TextAttributes.BOLD}> <box paddingLeft={4} paddingRight={4} paddingTop={1}>
Could not load skills <text fg={theme.textMuted}>No skills available</text>
</text> </box>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text> }
</box> >
) : undefined <Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
<text fg={theme.textMuted}>Close and reopen Skills to try again.</text>
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No skills found</text>
</box>
} }
/> />
) )

View file

@ -309,10 +309,10 @@ export function Autocomplete(props: {
} }
const [files] = createResource( const [files] = createResource(
() => ({ query: search(), location: location() }), () => ({ query: search(), location: location(), visible: store.visible }),
async (input) => { async (input) => {
if (!store.visible || store.visible === "/") return [] if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return [] if (referenceMatch()) return { options: [], failed: false }
const { lineRange, baseQuery } = extractLineRange(input.query ?? "") const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
const result = await client.api.file const result = await client.api.file
@ -324,34 +324,37 @@ export function Autocomplete(props: {
workspace: input.location?.workspaceID ?? project.workspace.current(), workspace: input.location?.workspaceID ?? project.workspace.current(),
}, },
}) })
.catch(() => undefined) .then(
(result) => result,
() => undefined,
)
if (!result) return { options: [], failed: true }
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
// Add file options. Trust the order returned by fff (frecency, fuzzy // Add file options. Trust the order returned by fff (frecency, fuzzy
// score, filename bonus, etc. are already factored in). // score, filename bonus, etc. are already factored in).
if (result) { const width = props.anchor().width - 4
const width = props.anchor().width - 4 options.push(
options.push( ...result.data.map((item): AutocompleteOption => {
...result.data.map((item): AutocompleteOption => { const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) return {
return { display: Locale.truncateMiddle(filename, width),
display: Locale.truncateMiddle(filename, width), value: filename,
value: filename, isDirectory: item.type === "directory",
isDirectory: item.type === "directory", path: item.path,
path: item.path, onSelect: () => {
onSelect: () => { insertPart(filename, part)
insertPart(filename, part) },
}, }
} }),
}), )
)
}
return options return { options, failed: false }
}, },
{ {
initialValue: [], initialValue: { options: [], failed: false },
}, },
) )
@ -470,8 +473,8 @@ export function Autocomplete(props: {
})) }))
}) })
const options = createMemo((prev: AutocompleteOption[] | undefined) => { const options = createMemo(() => {
const filesValue = files() const fileSearch = files()
const referenceMatchValue = referenceMatch() const referenceMatchValue = referenceMatch()
const agentsValue = agents() const agentsValue = agents()
const referenceAliasesValue = referenceAliases() const referenceAliasesValue = referenceAliases()
@ -484,7 +487,7 @@ export function Autocomplete(props: {
// Files come from fff already fuzzy ranked and filtered // Files come from fff already fuzzy ranked and filtered
// it shouldn't be additionally sorted by fuzzysort as it will loose the results // it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : [] const fileOptions: AutocompleteOption[] = store.visible === "@" && !files.loading ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] = const nonFileOptions: AutocompleteOption[] =
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
@ -492,10 +495,6 @@ export function Autocomplete(props: {
return [...nonFileOptions, ...fileOptions] return [...nonFileOptions, ...fileOptions]
} }
if (files.loading && prev && prev.length > 0) {
return prev
}
const fuzziedNonFiles = fuzzysort const fuzziedNonFiles = fuzzysort
.go(removeLineRange(searchValue), nonFileOptions, { .go(removeLineRange(searchValue), nonFileOptions, {
keys: [ keys: [
@ -715,6 +714,13 @@ export function Autocomplete(props: {
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const emptyMessage = createMemo(() => {
if (store.visible === "/") return "No matching commands"
if (files.loading) return "Searching…"
if (files().failed) return "Could not search files. Keep typing to try again."
return "No matching files, agents, or references"
})
const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed)
return ( return (
<box <box
@ -738,7 +744,7 @@ export function Autocomplete(props: {
each={options()} each={options()}
fallback={ fallback={
<box paddingLeft={1} paddingRight={1}> <box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>No matching items</text> <text fg={emptyError() ? theme.error : theme.textMuted}>{emptyMessage()}</text>
</box> </box>
} }
> >

View file

@ -104,16 +104,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
} }
}) })
const [diff] = createResource(diffInput, async (input) => { const [diff] = createResource(diffInput, async (input) => {
const result = await client.api.vcs.diff( const result = await client.api.vcs.diff({
{ location: input.directory ? { directory: input.directory } : undefined,
location: input.directory ? { directory: input.directory } : undefined, mode: input.mode,
mode: input.mode, context: VCS_DIFF_CONTEXT_LINES,
context: VCS_DIFF_CONTEXT_LINES, })
},
)
return normalizeDiffs(result.data ?? []) return normalizeDiffs(result.data ?? [])
}) })
const files = createMemo(() => diff() ?? []) const files = createMemo(() => (diff.error ? [] : (diff() ?? [])))
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches") const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true) const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true)
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
@ -748,9 +746,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<text fg={theme().text}>Diff </text> <text fg={theme().text}>Diff </text>
<text fg={theme().textMuted}>{diffSourceLabel(mode())}</text> <text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
<box flexGrow={1} /> <box flexGrow={1} />
<text fg={theme().textMuted}> <Show when={!diff.loading && !diff.error}>
{files().length} {files().length === 1 ? "file" : "files"} <text fg={theme().textMuted}>
</text> {files().length} {files().length === 1 ? "file" : "files"}
</text>
</Show>
</Panel> </Panel>
<box flexGrow={1} minHeight={0}> <box flexGrow={1} minHeight={0}>
@ -758,19 +758,19 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<Match when={diff.loading}> <Match when={diff.loading}>
<Separator axis="x" /> <Separator axis="x" />
<box flexGrow={1} paddingLeft={1}> <box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>Loading diff...</text> <text fg={theme().textMuted}>Loading diff</text>
</box>
</Match>
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>No diff!</text>
</box> </box>
</Match> </Match>
<Match when={!diff.loading && diff.error}> <Match when={!diff.loading && diff.error}>
<Separator axis="x" /> <Separator axis="x" />
<box flexGrow={1} paddingLeft={1}> <box flexGrow={1} paddingLeft={1}>
<text fg={theme().error}>Failed to load diff</text> <text fg={theme().error}>Could not load diff. Reopen the diff viewer to try again.</text>
</box>
</Match>
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>No changes to show</text>
</box> </box>
</Match> </Match>
<Match when={!diff.loading}> <Match when={!diff.loading}>

View file

@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
placeholder?: string placeholder?: string
footer?: JSX.Element footer?: JSX.Element
emptyView?: JSX.Element emptyView?: JSX.Element
noMatchView?: JSX.Element
options: DialogSelectOption<T>[] options: DialogSelectOption<T>[]
flat?: boolean flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void ref?: (ref: DialogSelectRef<T>) => void
@ -615,11 +616,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show <Show
when={grouped().length > 0} when={grouped().length > 0}
fallback={ fallback={
props.emptyView ?? ( <Show
<box paddingLeft={4} paddingRight={4} paddingTop={1}> when={props.renderFilter !== false && store.filter.length > 0}
<text fg={theme.textMuted}>No results found</text> fallback={
</box> props.emptyView ?? (
) <box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text>
</box>
)}
</Show>
} }
> >
<scrollbox <scrollbox

View file

@ -267,7 +267,7 @@ test("selects a repopulated option after removing the only option", async () =>
try { try {
select.replaceOptions([]) select.replaceOptions([])
await select.app.waitForFrame((frame) => frame.includes("No results found")) await select.app.waitForFrame((frame) => frame.includes("No items available"))
select.app.mockInput.pressEnter() select.app.mockInput.pressEnter()
expect(select.selected).toEqual([]) expect(select.selected).toEqual([])

View file

@ -36,6 +36,16 @@ test("closing the diff viewer returns to the route it opened from", async () =>
} }
}) })
test("shows an error instead of an empty diff when loading fails", async () => {
const viewer = await renderDiffViewer([], 20, undefined, true)
try {
await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff"))
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
} finally {
viewer.app.renderer.destroy()
}
})
test("brackets navigate diff hunks", async () => { test("brackets navigate diff hunks", async () => {
const viewer = await renderDiffViewer( const viewer = await renderDiffViewer(
[ [
@ -102,7 +112,7 @@ test("brackets navigate diff hunks", async () => {
} }
}) })
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) { async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent, fail = false) {
const commands = new Map< const commands = new Map<
string, string,
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number] NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
@ -113,6 +123,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
const config = createTuiResolvedConfig() const config = createTuiResolvedConfig()
const transport = createFetch((url) => { const transport = createFetch((url) => {
if (url.pathname !== "/api/vcs/diff") return if (url.pathname !== "/api/vcs/diff") return
if (fail) return json({ message: "boom" }, { status: 500 })
vcsDiffInput = { vcsDiffInput = {
location: { directory: url.searchParams.get("location[directory]") }, location: { directory: url.searchParams.get("location[directory]") },
mode: url.searchParams.get("mode"), mode: url.searchParams.get("mode"),