fix(tui): run dialog actions without selection (#36711)
This commit is contained in:
parent
a2f5a0df5d
commit
41349ff20a
5 changed files with 185 additions and 22 deletions
|
|
@ -132,6 +132,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||||
{
|
{
|
||||||
command: "model.dialog.provider",
|
command: "model.dialog.provider",
|
||||||
title: connected() ? "Connect integration" : "View all integrations",
|
title: connected() ? "Connect integration" : "View all integrations",
|
||||||
|
selection: "none",
|
||||||
onTrigger() {
|
onTrigger() {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogIntegration
|
<DialogIntegration
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||||
{
|
{
|
||||||
command: "dialog.move_session.new",
|
command: "dialog.move_session.new",
|
||||||
title: "new",
|
title: "new",
|
||||||
|
selection: "none",
|
||||||
onTrigger: () => void create(),
|
onTrigger: () => void create(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -360,6 +361,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||||
{
|
{
|
||||||
command: "dialog.move_session.refresh",
|
command: "dialog.move_session.refresh",
|
||||||
title: "refresh",
|
title: "refresh",
|
||||||
|
selection: "none",
|
||||||
onTrigger: () => void refetch(),
|
onTrigger: () => void refetch(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||||
{
|
{
|
||||||
title: "install",
|
title: "install",
|
||||||
command: "dialog.plugins.install",
|
command: "dialog.plugins.install",
|
||||||
|
selection: "none",
|
||||||
hidden: lock(),
|
hidden: lock(),
|
||||||
onTrigger: () => {
|
onTrigger: () => {
|
||||||
showInstall(props.api)
|
showInstall(props.api)
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,7 @@ export interface DialogSelectProps<T> {
|
||||||
renderFilter?: boolean
|
renderFilter?: boolean
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
preserveSelection?: boolean
|
preserveSelection?: boolean
|
||||||
actions?: {
|
actions?: DialogSelectAction<T>[]
|
||||||
command: string
|
|
||||||
title: string
|
|
||||||
side?: "left" | "right"
|
|
||||||
hidden?: boolean
|
|
||||||
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
|
||||||
onTrigger: (option: DialogSelectOption<T>) => void
|
|
||||||
}[]
|
|
||||||
footerHints?: {
|
footerHints?: {
|
||||||
title: string
|
title: string
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -54,6 +47,24 @@ export interface DialogSelectProps<T> {
|
||||||
focusCurrent?: boolean
|
focusCurrent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DialogSelectActionBase<T> = {
|
||||||
|
command: string
|
||||||
|
title: string
|
||||||
|
side?: "left" | "right"
|
||||||
|
hidden?: boolean
|
||||||
|
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogSelectAction<T> =
|
||||||
|
| (DialogSelectActionBase<T> & {
|
||||||
|
selection?: "required"
|
||||||
|
onTrigger: (option: DialogSelectOption<T>) => void
|
||||||
|
})
|
||||||
|
| (DialogSelectActionBase<T> & {
|
||||||
|
selection: "none"
|
||||||
|
onTrigger: () => void
|
||||||
|
})
|
||||||
|
|
||||||
export interface DialogSelectOption<T = any> {
|
export interface DialogSelectOption<T = any> {
|
||||||
title: string
|
title: string
|
||||||
titleView?: JSX.Element
|
titleView?: JSX.Element
|
||||||
|
|
@ -350,7 +361,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
const index = focusedAction()
|
const index = focusedAction()
|
||||||
if (index !== undefined) {
|
if (index !== undefined) {
|
||||||
triggerAction(actionItems()[index])
|
trigger(actionItems()[index])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const option = selected()
|
const option = selected()
|
||||||
|
|
@ -441,14 +452,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
name: item.command,
|
name: item.command,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
category: "Dialog",
|
category: "Dialog",
|
||||||
run() {
|
run: () => trigger(item),
|
||||||
if (props.locked) return
|
|
||||||
if (isActionDisabled(item)) return
|
|
||||||
setStore("input", "keyboard")
|
|
||||||
const option = selected()
|
|
||||||
if (!option) return
|
|
||||||
item.onTrigger(option)
|
|
||||||
},
|
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
bindings: [
|
bindings: [
|
||||||
|
|
@ -504,10 +508,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
||||||
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
||||||
|
|
||||||
function triggerAction(item: VisibleAction | undefined) {
|
function trigger(item: Action | undefined) {
|
||||||
if (props.locked) return
|
if (props.locked || !item || isActionDisabled(item)) return
|
||||||
if (!item || !isActionItem(item) || isActionDisabled(item)) return
|
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
|
if (item.selection === "none") {
|
||||||
|
item.onTrigger()
|
||||||
|
return
|
||||||
|
}
|
||||||
const option = selected()
|
const option = selected()
|
||||||
if (!option) return
|
if (!option) return
|
||||||
item.onTrigger(option)
|
item.onTrigger(option)
|
||||||
|
|
@ -518,7 +525,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActionDisabled(item: Action) {
|
function isActionDisabled(item: Action) {
|
||||||
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
|
const option = selected()
|
||||||
|
if (item.selection !== "none" && !option) return true
|
||||||
|
return typeof item.disabled === "function" ? item.disabled(option) : item.disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActionFocused(item: VisibleAction) {
|
function isActionFocused(item: VisibleAction) {
|
||||||
|
|
@ -545,7 +554,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||||
onMouseUp={() => triggerAction(item)}
|
onMouseUp={() => trigger(item)}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
|
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
|
||||||
|
|
|
||||||
150
packages/tui/test/cli/tui/dialog-select.test.tsx
Normal file
150
packages/tui/test/cli/tui/dialog-select.test.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { InputRenderable } from "@opentui/core"
|
||||||
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
|
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 type { DialogSelectOption } from "../../../src/ui/dialog-select"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
|
||||||
|
async function renderSelect(
|
||||||
|
root: string,
|
||||||
|
options: DialogSelectOption<string>[],
|
||||||
|
onGlobal: () => void,
|
||||||
|
onRow: (option: DialogSelectOption<string>) => void,
|
||||||
|
) {
|
||||||
|
const state = path.join(root, "state")
|
||||||
|
await mkdir(state, { recursive: true })
|
||||||
|
const config = createTuiResolvedConfig()
|
||||||
|
const [
|
||||||
|
{ ConfigProvider },
|
||||||
|
{ ThemeProvider },
|
||||||
|
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
||||||
|
{ DialogProvider },
|
||||||
|
{ 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"),
|
||||||
|
])
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
const off = registerOpencodeKeymap(keymap, renderer, config)
|
||||||
|
onCleanup(off)
|
||||||
|
|
||||||
|
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>
|
||||||
|
<DialogSelect
|
||||||
|
title="Items"
|
||||||
|
options={options}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
command: "dialog.move_session.delete",
|
||||||
|
title: "delete",
|
||||||
|
onTrigger: onRow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "dialog.move_session.new",
|
||||||
|
title: "new",
|
||||||
|
selection: "none",
|
||||||
|
onTrigger: onGlobal,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</DialogProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
</OpencodeKeymapProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
|
||||||
|
app.renderer.start()
|
||||||
|
await app.waitForFrame((frame) => frame.includes("Items"))
|
||||||
|
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
test("dialog actions run without options while row actions still require a selection", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
let global = 0
|
||||||
|
const rows: string[] = []
|
||||||
|
const app = await renderSelect(
|
||||||
|
tmp.path,
|
||||||
|
[],
|
||||||
|
() => global++,
|
||||||
|
(option) => rows.push(option.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.mockInput.pressKey("m", { ctrl: true })
|
||||||
|
app.mockInput.pressKey("d", { ctrl: true })
|
||||||
|
|
||||||
|
expect(global).toBe(1)
|
||||||
|
expect(rows).toEqual([])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("footer actions run when filtering leaves no selected row", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
let global = 0
|
||||||
|
const rows: string[] = []
|
||||||
|
const app = await renderSelect(
|
||||||
|
tmp.path,
|
||||||
|
[{ title: "Alpha", value: "alpha" }],
|
||||||
|
() => global++,
|
||||||
|
(option) => rows.push(option.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const key of "missing") app.mockInput.pressKey(key)
|
||||||
|
await app.waitForFrame((frame) => frame.includes("No results found"))
|
||||||
|
|
||||||
|
app.mockInput.pressKey("d", { ctrl: true })
|
||||||
|
app.mockInput.pressTab()
|
||||||
|
app.mockInput.pressEnter()
|
||||||
|
|
||||||
|
expect(global).toBe(1)
|
||||||
|
expect(rows).toEqual([])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("row actions receive the selected option", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const rows: string[] = []
|
||||||
|
const app = await renderSelect(
|
||||||
|
tmp.path,
|
||||||
|
[{ title: "Alpha", value: "alpha" }],
|
||||||
|
() => {},
|
||||||
|
(option) => rows.push(option.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.mockInput.pressKey("d", { ctrl: true })
|
||||||
|
|
||||||
|
expect(rows).toEqual(["alpha"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue