Explorar el Código

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

Kit Langton hace 1 mes
padre
commit
ed1636e3bd

+ 5 - 1
packages/tui/src/ui/dialog-select.tsx

@@ -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

+ 115 - 1
packages/tui/test/cli/tui/dialog-select.test.tsx

@@ -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()
+  }
+})