fix(tui): distinguish asynchronous UI states (#36759)
This commit is contained in:
parent
ea386fba1f
commit
26e27c7a7f
10 changed files with 219 additions and 116 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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([])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue