mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
fix(tui): distinguish asynchronous UI states (#36759)
This commit is contained in:
@@ -90,6 +90,11 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
|
||||
<text fg={theme.textMuted}>No integrations available</text>
|
||||
</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"
|
||||
onConfirm={(key) => {
|
||||
if (!key) return
|
||||
void client.api.integration
|
||||
.connect.key({
|
||||
void client.api.integration.connect
|
||||
.key({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
key,
|
||||
@@ -222,8 +227,8 @@ function OAuthStarting(props: {
|
||||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
void client.api.integration
|
||||
.connect.oauth({
|
||||
void client.api.integration.connect
|
||||
.oauth({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
methodID: props.method.id,
|
||||
@@ -291,8 +296,8 @@ function OAuthAuto(props: {
|
||||
}))
|
||||
|
||||
const poll = () => {
|
||||
void client.api.integration
|
||||
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void client.api.integration.attempt
|
||||
.status({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
.then((result) => {
|
||||
const status = result.data
|
||||
if (status.status === "pending") {
|
||||
@@ -357,8 +362,8 @@ function OAuthCode(props: {
|
||||
placeholder="Authorization code"
|
||||
onConfirm={(code) => {
|
||||
if (!code) return
|
||||
void client.api.integration
|
||||
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
void client.api.integration.attempt
|
||||
.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
.then(() => {
|
||||
settled = true
|
||||
return connected(props.integration, data, dialog, toast, props.onConnected)
|
||||
|
||||
@@ -21,7 +21,9 @@ import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
|
||||
import { useRoute } from "../context/route"
|
||||
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 DialogMoveSessionProps = {
|
||||
@@ -120,7 +122,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (showError()) return []
|
||||
const data = directoryData()
|
||||
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 ?? [])]
|
||||
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
|
||||
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
|
||||
return 0
|
||||
})
|
||||
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||
if (roots.length === 0) return []
|
||||
|
||||
const subdirectories = sessionData.session
|
||||
.list()
|
||||
.filter(
|
||||
(session) =>
|
||||
session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
||||
(session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
||||
)
|
||||
.map((session) => session.location.directory)
|
||||
.filter((directory) => !roots.some((root) => root.directory === directory))
|
||||
@@ -326,13 +327,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
options={options()}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.textMuted}>Close and reopen Move session to try again.</text>
|
||||
</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())}
|
||||
current={current()}
|
||||
|
||||
@@ -25,12 +25,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
dialog.setCentered(true)
|
||||
|
||||
const [server] = createResource(() =>
|
||||
client.api.server
|
||||
.get()
|
||||
.catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
client.api.server.get().catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
const info = createMemo(() => {
|
||||
const current = server()
|
||||
@@ -46,11 +44,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const value = info()
|
||||
if (!value) return
|
||||
return (
|
||||
<box
|
||||
flexDirection={horizontal() ? "row" : "column"}
|
||||
alignItems={horizontal() ? "flex-start" : "center"}
|
||||
gap={2}
|
||||
>
|
||||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.textMuted}>URLs</text>
|
||||
@@ -72,9 +66,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
{showPassword() ? value.password : "************"}
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}
|
||||
>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
@@ -102,23 +94,35 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={loadError()}>
|
||||
{(error) => <text fg={theme.error}>{errorMessage(error())}</text>}
|
||||
</Show>
|
||||
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information...</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
<scrollbox
|
||||
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
<Show
|
||||
when={loadError()}
|
||||
fallback={
|
||||
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information…</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
<scrollbox
|
||||
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
{content()}
|
||||
</scrollbox>
|
||||
}
|
||||
>
|
||||
{content()}
|
||||
</scrollbox>
|
||||
}
|
||||
>
|
||||
{content()}
|
||||
</Show>
|
||||
</Show>
|
||||
</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>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ export function DialogSessionList() {
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||
@@ -44,21 +45,38 @@ export function DialogSessionList() {
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
})
|
||||
return { query, sessions: response.data }
|
||||
return { query, sessions: response.data, error: undefined }
|
||||
} catch (error) {
|
||||
// A transient transport failure must degrade search, not crash the TUI
|
||||
// through the root ErrorBoundary when the errored resource is read.
|
||||
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
|
||||
return { query, sessions: [] as SessionInfo[] }
|
||||
return { query, sessions: [] as SessionInfo[], error }
|
||||
}
|
||||
})
|
||||
|
||||
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 query = search()
|
||||
if (!query) return data.session.list()
|
||||
const query = filter()
|
||||
const local = localSessions()
|
||||
if (!query) return local
|
||||
if (query !== search() || searchResults.loading) return local
|
||||
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(() => {
|
||||
@@ -120,7 +138,20 @@ export function DialogSessionList() {
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
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)}
|
||||
onSelect={(option) => {
|
||||
route.navigate({ type: "session", sessionID: option.value })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
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 { useTheme } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -57,17 +57,36 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
<DialogSelect
|
||||
title="Skills"
|
||||
options={options()}
|
||||
renderFilter={!showError()}
|
||||
locked={showError()}
|
||||
renderFilter={!showError() && !skills.loading}
|
||||
locked={showError() || skills.loading}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
</box>
|
||||
) : undefined
|
||||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -309,10 +309,10 @@ export function Autocomplete(props: {
|
||||
}
|
||||
|
||||
const [files] = createResource(
|
||||
() => ({ query: search(), location: location() }),
|
||||
() => ({ query: search(), location: location(), visible: store.visible }),
|
||||
async (input) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
if (referenceMatch()) return []
|
||||
if (!input.visible || input.visible === "/") return { options: [], failed: false }
|
||||
if (referenceMatch()) return { options: [], failed: false }
|
||||
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
|
||||
|
||||
const result = await client.api.file
|
||||
@@ -324,34 +324,37 @@ export function Autocomplete(props: {
|
||||
workspace: input.location?.workspaceID ?? project.workspace.current(),
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.then(
|
||||
(result) => result,
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
if (!result) return { options: [], failed: true }
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
|
||||
// Add file options. Trust the order returned by fff (frecency, fuzzy
|
||||
// score, filename bonus, etc. are already factored in).
|
||||
if (result) {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...result.data.map((item): AutocompleteOption => {
|
||||
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
value: filename,
|
||||
isDirectory: item.type === "directory",
|
||||
path: item.path,
|
||||
onSelect: () => {
|
||||
insertPart(filename, part)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...result.data.map((item): AutocompleteOption => {
|
||||
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
value: filename,
|
||||
isDirectory: item.type === "directory",
|
||||
path: item.path,
|
||||
onSelect: () => {
|
||||
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 filesValue = files()
|
||||
const options = createMemo(() => {
|
||||
const fileSearch = files()
|
||||
const referenceMatchValue = referenceMatch()
|
||||
const agentsValue = agents()
|
||||
const referenceAliasesValue = referenceAliases()
|
||||
@@ -484,7 +487,7 @@ export function Autocomplete(props: {
|
||||
|
||||
// Files come from fff already fuzzy ranked and filtered
|
||||
// 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[] =
|
||||
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
|
||||
|
||||
@@ -492,10 +495,6 @@ export function Autocomplete(props: {
|
||||
return [...nonFileOptions, ...fileOptions]
|
||||
}
|
||||
|
||||
if (files.loading && prev && prev.length > 0) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const fuzziedNonFiles = fuzzysort
|
||||
.go(removeLineRange(searchValue), nonFileOptions, {
|
||||
keys: [
|
||||
@@ -715,6 +714,13 @@ export function Autocomplete(props: {
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
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 (
|
||||
<box
|
||||
@@ -738,7 +744,7 @@ export function Autocomplete(props: {
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted}>No matching items</text>
|
||||
<text fg={emptyError() ? theme.error : theme.textMuted}>{emptyMessage()}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -104,16 +104,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
}
|
||||
})
|
||||
const [diff] = createResource(diffInput, async (input) => {
|
||||
const result = await client.api.vcs.diff(
|
||||
{
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
mode: input.mode,
|
||||
context: VCS_DIFF_CONTEXT_LINES,
|
||||
},
|
||||
)
|
||||
const result = await client.api.vcs.diff({
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
mode: input.mode,
|
||||
context: VCS_DIFF_CONTEXT_LINES,
|
||||
})
|
||||
return normalizeDiffs(result.data ?? [])
|
||||
})
|
||||
const files = createMemo(() => diff() ?? [])
|
||||
const files = createMemo(() => (diff.error ? [] : (diff() ?? [])))
|
||||
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true)
|
||||
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().textMuted}>{diffSourceLabel(mode())}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme().textMuted}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
</text>
|
||||
<Show when={!diff.loading && !diff.error}>
|
||||
<text fg={theme().textMuted}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
</text>
|
||||
</Show>
|
||||
</Panel>
|
||||
|
||||
<box flexGrow={1} minHeight={0}>
|
||||
@@ -758,19 +758,19 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
<Match when={diff.loading}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<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>
|
||||
<text fg={theme().textMuted}>Loading diff…</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading && diff.error}>
|
||||
<Separator axis="x" />
|
||||
<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>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
|
||||
placeholder?: string
|
||||
footer?: JSX.Element
|
||||
emptyView?: JSX.Element
|
||||
noMatchView?: JSX.Element
|
||||
options: DialogSelectOption<T>[]
|
||||
flat?: boolean
|
||||
ref?: (ref: DialogSelectRef<T>) => void
|
||||
@@ -615,11 +616,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<Show
|
||||
when={grouped().length > 0}
|
||||
fallback={
|
||||
props.emptyView ?? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No results found</text>
|
||||
</box>
|
||||
)
|
||||
<Show
|
||||
when={props.renderFilter !== false && store.filter.length > 0}
|
||||
fallback={
|
||||
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
|
||||
|
||||
@@ -267,7 +267,7 @@ test("selects a repopulated option after removing the only option", async () =>
|
||||
|
||||
try {
|
||||
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()
|
||||
expect(select.selected).toEqual([])
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
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<
|
||||
string,
|
||||
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
|
||||
@@ -113,6 +123,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
const config = createTuiResolvedConfig()
|
||||
const transport = createFetch((url) => {
|
||||
if (url.pathname !== "/api/vcs/diff") return
|
||||
if (fail) return json({ message: "boom" }, { status: 500 })
|
||||
vcsDiffInput = {
|
||||
location: { directory: url.searchParams.get("location[directory]") },
|
||||
mode: url.searchParams.get("mode"),
|
||||
|
||||
Reference in New Issue
Block a user