fix(tui): clamp dialog selection after options shrink (#36712)

This commit is contained in:
Kit Langton 2026-07-13 15:59:04 -04:00 committed by GitHub
commit ed1636e3bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 120 additions and 2 deletions

View file

@ -233,7 +233,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (!props.preserveSelection) return
if (!props.preserveSelection) {
const next = Math.min(store.selected, flat().length - 1)
if (next >= 0 && next !== store.selected) setStore("selected", next)
return
}
if (resetSelection && store.filter.length > 0) {
const option = flat()[0]
if (!option) return

View file

@ -5,7 +5,7 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import { createSignal, onCleanup, onMount } from "solid-js"
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
@ -82,6 +82,77 @@ async function renderSelect(
return app
}
async function mountSelect(root: string, initial: DialogSelectOption<string>[]) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [
{ ConfigProvider },
{ ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
{ DialogProvider, useDialog },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
const selected: string[] = []
const moved: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
const [options, setOptions] = createSignal(initial)
replaceOptions = setOptions
onCleanup(off)
function Fixture() {
const dialog = useDialog()
onMount(() =>
dialog.replace(() => (
<DialogSelect
title="Mutable options"
options={options()}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
)),
)
return null
}
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 24, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, selected }
}
test("dialog actions run without options while row actions still require a selection", async () => {
await using tmp = await tmpdir()
let global = 0
@ -148,3 +219,46 @@ test("row actions receive the selected option", async () => {
app.renderer.destroy()
}
})
test("selects the new final option immediately after removing the selected final option", async () => {
await using tmp = await tmpdir()
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
const select = await mountSelect(tmp.path, options)
try {
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "second")
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "third")
select.replaceOptions(options.slice(0, -1))
await select.app.waitForFrame((frame) => !frame.includes("third"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["second"])
} finally {
select.app.renderer.destroy()
}
})
test("selects a repopulated option after removing the only option", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "only", value: "only" }])
try {
select.replaceOptions([])
await select.app.waitForFrame((frame) => frame.includes("No results found"))
select.app.mockInput.pressEnter()
expect(select.selected).toEqual([])
select.replaceOptions([{ title: "replacement", value: "replacement" }])
await select.app.waitForFrame((frame) => frame.includes("replacement"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["replacement"])
} finally {
select.app.renderer.destroy()
}
})