diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 40ad0f24b143..b513e2faed54 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -106,7 +106,7 @@ export function DialogSelect(props: DialogSelectProps) { filter: "", input: "keyboard" as "keyboard" | "mouse", }) - const [focusedAction, setFocusedAction] = createSignal() + const [focusedAction, setFocusedAction] = createSignal() const actionFocused = createMemo(() => focusedAction() !== undefined) let selection: { value: T; category?: string } | undefined let resetSelection = false @@ -130,42 +130,6 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable - const actions = createMemo(() => props.actions ?? []) - const shownActions = createMemo(() => actions().filter((item) => !item.hidden)) - const actionBindings = useKeymapSelector((keymap) => - keymap.getCommandBindings({ - visibility: "registered", - commands: shownActions().map((item) => item.command), - }), - ) - - const actionLabels = createMemo(() => { - const labels = new Map() - - for (const action of shownActions()) { - const label = formatKeyBindings(actionBindings().get(action.command), config) - if (label) labels.set(action.command, label) - } - - return labels - }) - const visibleActions = createMemo(() => [ - ...shownActions() - .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) - .filter((item) => item.label), - ...(props.footerHints ?? []), - ]) - const actionItems = createMemo(() => - visibleActions() - .filter(isActionItem) - .filter((item) => !isActionDisabled(item)), - ) - - createEffect(() => { - const index = focusedAction() - if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined) - }) - const filtered = createMemo(() => { if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() @@ -229,6 +193,44 @@ export function DialogSelect(props: DialogSelectProps) { const selected = createMemo(() => flat()[store.selected]) + // Action availability depends on the selected option, so initialize the + // option graph before registering action bindings that may run immediately. + const actions = createMemo(() => props.actions ?? []) + const shownActions = createMemo(() => actions().filter((item) => !item.hidden)) + const actionBindings = useKeymapSelector((keymap) => + keymap.getCommandBindings({ + visibility: "registered", + commands: shownActions().map((item) => item.command), + }), + ) + + const actionLabels = createMemo(() => { + const labels = new Map() + + for (const action of shownActions()) { + const label = formatKeyBindings(actionBindings().get(action.command), config) + if (label) labels.set(action.command, label) + } + + return labels + }) + const visibleActions = createMemo(() => [ + ...shownActions() + .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) + .filter((item) => item.label), + ...(props.footerHints ?? []), + ]) + const actionItems = createMemo(() => + visibleActions() + .filter(isActionItem) + .filter((item) => !isActionDisabled(item)), + ) + + createEffect(() => { + const command = focusedAction() + if (command && !actionItems().some((item) => item.command === command)) setFocusedAction(undefined) + }) + createEffect( on( () => props.options, @@ -363,9 +365,9 @@ export function DialogSelect(props: DialogSelectProps) { function submit() { if (props.locked) return setStore("input", "keyboard") - const index = focusedAction() - if (index !== undefined) { - trigger(actionItems()[index]) + const command = focusedAction() + if (command) { + trigger(actionItems().find((item) => item.command === command)) return } const option = selected() @@ -376,12 +378,14 @@ export function DialogSelect(props: DialogSelectProps) { function moveAction(direction: 1 | -1) { if (props.locked) return - const total = actionItems().length - if (total === 0) return - setFocusedAction((index) => { - if (index === undefined) return direction === 1 ? 0 : total - 1 + const items = actionItems() + if (items.length === 0) return + setFocusedAction((command) => { + if (!command) return items[direction === 1 ? 0 : items.length - 1].command + const index = items.findIndex((item) => item.command === command) + if (index === -1) return items[direction === 1 ? 0 : items.length - 1].command const next = index + direction - return next < 0 || next >= total ? undefined : next + return next < 0 || next >= items.length ? undefined : items[next].command }) } @@ -537,7 +541,7 @@ export function DialogSelect(props: DialogSelectProps) { function isActionFocused(item: VisibleAction) { if (props.locked) return false if (!isActionItem(item)) return false - return actionItems().indexOf(item) === focusedAction() + return item.command === focusedAction() } function FooterAction(action: { item: VisibleAction }) { diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 96c849186dbd..60e791dc2534 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -104,14 +104,19 @@ async function mountSelect(root: string, initial: DialogSelectOption[]) const selected: string[] = [] const moved: string[] = [] + const globals: number[] = [] + const rows: string[] = [] let replaceOptions!: (options: DialogSelectOption[]) => void + let disableRow!: () => void function Harness() { const renderer = useRenderer() const keymap = createDefaultOpenTuiKeymap(renderer) const off = registerOpencodeKeymap(keymap, renderer, config) const [options, setOptions] = createSignal(initial) + const [rowEnabled, setRowEnabled] = createSignal(true) replaceOptions = setOptions + disableRow = () => setRowEnabled(false) onCleanup(off) function Fixture() { @@ -121,8 +126,23 @@ async function mountSelect(root: string, initial: DialogSelectOption[]) moved.push(option.value)} onSelect={(option) => selected.push(option.value)} + actions={[ + { + command: "dialog.move_session.delete", + title: "delete", + disabled: !rowEnabled(), + onTrigger: (option) => rows.push(option.value), + }, + { + command: "dialog.move_session.new", + title: "new", + selection: "none", + onTrigger: () => globals.push(1), + }, + ]} /> )), ) @@ -150,7 +170,7 @@ async function mountSelect(root: string, initial: DialogSelectOption[]) app.renderer.start() await app.waitForFrame((frame) => frame.includes("Mutable options")) await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) - return { app, moved, replaceOptions, selected } + return { app, disableRow, globals, moved, replaceOptions, rows, selected } } test("dialog actions run without options while row actions still require a selection", async () => { @@ -201,6 +221,23 @@ test("footer actions run when filtering leaves no selected row", async () => { } }) +test("does not move focus when the focused action becomes disabled", async () => { + await using tmp = await tmpdir() + const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }]) + + try { + select.app.mockInput.pressTab() + select.disableRow() + select.app.mockInput.pressEnter() + + expect(select.globals).toEqual([]) + expect(select.rows).toEqual([]) + expect(select.selected).toEqual(["alpha"]) + } finally { + select.app.renderer.destroy() + } +}) + test("row actions receive the selected option", async () => { await using tmp = await tmpdir() const rows: string[] = []