Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e255a26527 |
@@ -1,5 +1,5 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
import { batch, createMemo, For, Match, Show, Switch } from "solid-js"
|
||||||
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import type { TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeybind } from "../../context/keybind"
|
import { useKeybind } from "../../context/keybind"
|
||||||
@@ -549,7 +549,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Prompt<const T extends Record<string, string>>(props: {
|
export function Prompt<const T extends Record<string, string>>(props: {
|
||||||
title: string
|
title: string
|
||||||
header?: JSX.Element
|
header?: JSX.Element
|
||||||
body: JSX.Element
|
body: JSX.Element
|
||||||
@@ -564,6 +564,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
const keys = Object.keys(props.options) as (keyof T)[]
|
const keys = Object.keys(props.options) as (keyof T)[]
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
selected: keys[0],
|
selected: keys[0],
|
||||||
|
hovered: undefined as keyof T | undefined,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
})
|
})
|
||||||
const diffKey = Keybind.parse("ctrl+f")[0]
|
const diffKey = Keybind.parse("ctrl+f")[0]
|
||||||
@@ -577,14 +578,20 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
const idx = keys.indexOf(store.selected)
|
const idx = keys.indexOf(store.selected)
|
||||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
const next = keys[(idx - 1 + keys.length) % keys.length]
|
||||||
setStore("selected", next)
|
batch(() => {
|
||||||
|
setStore("selected", next)
|
||||||
|
setStore("hovered", undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "right" || evt.name == "l") {
|
if (evt.name === "right" || evt.name == "l") {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
const idx = keys.indexOf(store.selected)
|
const idx = keys.indexOf(store.selected)
|
||||||
const next = keys[(idx + 1) % keys.length]
|
const next = keys[(idx + 1) % keys.length]
|
||||||
setStore("selected", next)
|
batch(() => {
|
||||||
|
setStore("selected", next)
|
||||||
|
setStore("hovered", undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.name === "return") {
|
if (evt.name === "return") {
|
||||||
@@ -605,6 +612,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
|
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
|
||||||
|
const active = (option: keyof T) => option === (store.hovered ?? store.selected)
|
||||||
useRenderer()
|
useRenderer()
|
||||||
|
|
||||||
const content = () => (
|
const content = () => (
|
||||||
@@ -658,14 +666,18 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
backgroundColor={active(option) ? theme.warning : theme.backgroundMenu}
|
||||||
onMouseOver={() => setStore("selected", option)}
|
onMouseOver={() => setStore("hovered", option)}
|
||||||
|
onMouseOut={() => {
|
||||||
|
if (store.hovered !== option) return
|
||||||
|
setStore("hovered", undefined)
|
||||||
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
setStore("selected", option)
|
setStore("selected", option)
|
||||||
props.onSelect(option)
|
props.onSelect(option)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
|
<text fg={active(option) ? selectedForeground(theme, theme.warning) : theme.textMuted}>
|
||||||
{props.options[option]}
|
{props.options[option]}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import path from "path"
|
||||||
|
import { DialogProvider } from "../../../src/cli/cmd/tui/ui/dialog"
|
||||||
|
import { ToastProvider } from "../../../src/cli/cmd/tui/ui/toast"
|
||||||
|
import { KeybindProvider } from "../../../src/cli/cmd/tui/context/keybind"
|
||||||
|
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
|
||||||
|
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
|
||||||
|
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
|
||||||
|
import { Prompt } from "../../../src/cli/cmd/tui/routes/session/permission"
|
||||||
|
import { Global } from "../../../src/global"
|
||||||
|
|
||||||
|
type Setup = Awaited<ReturnType<typeof testRender>>
|
||||||
|
|
||||||
|
function App(props: { onSelect: (option: "once" | "always" | "reject") => void }) {
|
||||||
|
return (
|
||||||
|
<TuiConfigProvider config={{}}>
|
||||||
|
<KVProvider>
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<ToastProvider>
|
||||||
|
<DialogProvider>
|
||||||
|
<KeybindProvider>
|
||||||
|
<Prompt
|
||||||
|
title="Permission required"
|
||||||
|
body={
|
||||||
|
<box>
|
||||||
|
<text>Prompt body</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||||
|
onSelect={props.onSelect}
|
||||||
|
/>
|
||||||
|
</KeybindProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</KVProvider>
|
||||||
|
</TuiConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function locate(frame: string, text: string) {
|
||||||
|
const lines = frame.split("\n")
|
||||||
|
const y = lines.findIndex((line) => line.includes(text))
|
||||||
|
if (y === -1) throw new Error(`Could not locate ${text}`)
|
||||||
|
return { x: lines[y]!.indexOf(text), y }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPrompt(setup: Setup) {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await setup.renderOnce()
|
||||||
|
const frame = setup.captureCharFrame()
|
||||||
|
if (frame.includes("Allow once") && frame.includes("Allow always") && frame.includes("Reject")) {
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Timed out waiting for permission prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareKv() {
|
||||||
|
await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("permission prompt", () => {
|
||||||
|
test("hover does not change the option Enter confirms", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
await prepareKv()
|
||||||
|
const setup = await testRender(() => <App onSelect={(option) => calls.push(option)} />, {
|
||||||
|
width: 80,
|
||||||
|
height: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frame = await waitForPrompt(setup)
|
||||||
|
const reject = locate(frame, "Reject")
|
||||||
|
|
||||||
|
setup.mockInput.pressArrow("right")
|
||||||
|
await setup.renderOnce()
|
||||||
|
|
||||||
|
await setup.mockMouse.moveTo(reject.x, reject.y)
|
||||||
|
await setup.renderOnce()
|
||||||
|
|
||||||
|
setup.mockInput.pressEnter()
|
||||||
|
|
||||||
|
expect(calls).toEqual(["always"])
|
||||||
|
} finally {
|
||||||
|
setup.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("click still selects the hovered option", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
await prepareKv()
|
||||||
|
const setup = await testRender(() => <App onSelect={(option) => calls.push(option)} />, {
|
||||||
|
width: 80,
|
||||||
|
height: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frame = await waitForPrompt(setup)
|
||||||
|
const reject = locate(frame, "Reject")
|
||||||
|
|
||||||
|
setup.mockInput.pressArrow("right")
|
||||||
|
await setup.renderOnce()
|
||||||
|
|
||||||
|
await setup.mockMouse.click(reject.x, reject.y)
|
||||||
|
|
||||||
|
expect(calls).toEqual(["reject"])
|
||||||
|
} finally {
|
||||||
|
setup.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user