mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-04 06:26:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de305669c2 | ||
|
|
1bbe738c73 |
No files matched your search
@@ -361,11 +361,34 @@ export interface DialogSelectOption<Value> {
|
||||
readonly disabled?: boolean
|
||||
}
|
||||
|
||||
export type DialogSelectAction<Value> = {
|
||||
readonly bind: string
|
||||
readonly title: string
|
||||
readonly side?: "left" | "right"
|
||||
} & (
|
||||
| {
|
||||
/** Unavailable while no option is selected. */
|
||||
readonly selection?: "required"
|
||||
readonly onTrigger: (value: Value) => void
|
||||
}
|
||||
| {
|
||||
readonly selection: "none"
|
||||
readonly onTrigger: () => void
|
||||
}
|
||||
)
|
||||
|
||||
export interface DialogSelectOptions<Value> {
|
||||
readonly title: string
|
||||
readonly placeholder?: string
|
||||
readonly options: readonly DialogSelectOption<Value>[]
|
||||
readonly current?: Value
|
||||
/** Returns matching options in display order, including for an empty query. Receives only enabled options. Results are listed flat. */
|
||||
readonly search?: (
|
||||
query: string,
|
||||
options: readonly DialogSelectOption<Value>[],
|
||||
) => readonly DialogSelectOption<Value>[]
|
||||
/** Footer actions, also triggered by their bindings. Triggering one does not close the dialog. */
|
||||
readonly actions?: readonly DialogSelectAction<Value>[]
|
||||
}
|
||||
|
||||
export interface Dialog {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { PluginContextProvider } from "@opencode/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode/plugin/tui/context"
|
||||
import { createUniqueId, type JSX } from "solid-js"
|
||||
import type {
|
||||
Context,
|
||||
Dialog,
|
||||
DialogSelectOptions,
|
||||
Page,
|
||||
SlotClaim,
|
||||
SlotMap,
|
||||
SlotPath,
|
||||
Toast,
|
||||
} from "@opencode/plugin/tui/context"
|
||||
import type { Placement, PlacementKind } from "./structure"
|
||||
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
@@ -356,16 +365,37 @@ export function createDialogApi(
|
||||
)
|
||||
})
|
||||
},
|
||||
select(options) {
|
||||
return new Promise((resolve) => {
|
||||
const done = settle<(typeof options.options)[number]["value"] | undefined>(resolve)
|
||||
select<Value>(options: DialogSelectOptions<Value>) {
|
||||
return new Promise<Value | undefined>((resolve) => {
|
||||
const done = settle<Value | undefined>(resolve)
|
||||
const search = options.search
|
||||
const id = createUniqueId()
|
||||
api.show(
|
||||
() => (
|
||||
<DialogSelect
|
||||
<DialogSelect<Value>
|
||||
title={options.title}
|
||||
placeholder={options.placeholder}
|
||||
options={options.options.map((option) => ({ ...option }))}
|
||||
current={options.current}
|
||||
search={
|
||||
search &&
|
||||
((query) =>
|
||||
search(
|
||||
query,
|
||||
options.options.filter((option) => !option.disabled),
|
||||
))
|
||||
}
|
||||
actions={options.actions?.map((action, index) => {
|
||||
const base = {
|
||||
command: `plugin.dialog.select.${id}.${index}`,
|
||||
title: action.title,
|
||||
side: action.side,
|
||||
bind: action.bind,
|
||||
}
|
||||
if (action.selection === "none")
|
||||
return { ...base, selection: action.selection, onTrigger: action.onTrigger }
|
||||
return { ...base, onTrigger: (option) => action.onTrigger(option.value) }
|
||||
})}
|
||||
onSelect={(option) => {
|
||||
done(option.value)
|
||||
api.clear()
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface DialogSelectProps<T> {
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
onCancel?: () => void
|
||||
skipFilter?: boolean
|
||||
search?: (query: string) => readonly DialogSelectOption<T>[]
|
||||
renderFilter?: boolean
|
||||
locked?: boolean
|
||||
preserveSelection?: boolean
|
||||
@@ -48,6 +49,7 @@ export interface DialogSelectProps<T> {
|
||||
type DialogSelectActionBase<T> = {
|
||||
command: string
|
||||
title: string
|
||||
bind?: string
|
||||
side?: "left" | "right"
|
||||
hidden?: boolean
|
||||
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
||||
@@ -136,7 +138,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
on(
|
||||
[() => props.focusTarget ?? props.current, () => (props.focusTarget === undefined ? undefined : flat())],
|
||||
([current]) => {
|
||||
if (props.focusCurrent === false) return
|
||||
if (props.search || props.focusCurrent === false) return
|
||||
if (props.focusTarget !== undefined && (props.preserveSelection || store.filter.length > 0)) return
|
||||
if (current !== undefined) {
|
||||
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
||||
@@ -183,6 +185,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
})
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
if (props.search) return props.search(store.filter).filter((x) => x.disabled !== true)
|
||||
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
||||
const needle = store.filter.toLowerCase()
|
||||
const options = pipe(
|
||||
@@ -209,7 +212,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setFocusedAction(undefined)
|
||||
})
|
||||
|
||||
const flatten = createMemo(() => props.flat && store.filter.length > 0)
|
||||
const flatten = createMemo(() => props.search !== undefined || (props.flat && store.filter.length > 0))
|
||||
|
||||
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
|
||||
if (flatten()) return filtered().length ? [["", filtered()]] : []
|
||||
@@ -246,6 +249,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
on(
|
||||
() => props.options,
|
||||
() => {
|
||||
if (props.search) return
|
||||
if (
|
||||
!props.preserveSelection &&
|
||||
((props.focusTarget ?? props.current) === undefined || props.focusCurrent === false)
|
||||
@@ -302,6 +306,36 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
flat,
|
||||
() => store.filter,
|
||||
() => (props.focusCurrent === false ? undefined : (props.focusTarget ?? props.current)),
|
||||
],
|
||||
([options, query, current], previous) => {
|
||||
if (!props.search) return
|
||||
const queryChanged = previous !== undefined && query !== previous[1]
|
||||
const currentChanged = current !== undefined && (previous === undefined || !isDeepEqual(current, previous[2]))
|
||||
selection = intent()
|
||||
const intended = selection
|
||||
const index = intended ? options.findIndex((option) => isDeepEqual(option.value, intended.value)) : -1
|
||||
const next = index >= 0 ? index : reconcileSelection(store.selected, options.length)
|
||||
const option = options[next]
|
||||
if (!option) return
|
||||
setStore("selected", next)
|
||||
selection = option
|
||||
scrollAfterLayout(queryChanged || currentChanged, option.value)
|
||||
|
||||
function intent() {
|
||||
if (queryChanged && query) return options[0]
|
||||
if ((queryChanged || currentChanged) && current !== undefined) return { value: current }
|
||||
if (queryChanged) return options[reconcileSelection(store.selected, options.length)]
|
||||
return selection
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
onCleanup(() => {
|
||||
if (!pendingScroll) return
|
||||
renderer.off(CliRenderEvents.FRAME, pendingScroll)
|
||||
@@ -310,6 +344,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
|
||||
createEffect(
|
||||
on([() => store.filter, () => props.focusTarget ?? props.current], ([filter, current]) => {
|
||||
if (props.search) return
|
||||
if (filter.length > 0) resetSelection = true
|
||||
if (filter.length > 0) {
|
||||
const option = flat()[0]
|
||||
@@ -476,6 +511,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
id: item.command,
|
||||
title: item.title,
|
||||
group: "Dialog",
|
||||
bind: item.bind,
|
||||
run: () => trigger(item),
|
||||
})),
|
||||
...(visible.length
|
||||
|
||||
@@ -4,7 +4,7 @@ import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { dialogWidth } from "../../../src/ui/dialog"
|
||||
import {
|
||||
dialogSelectContentWidth,
|
||||
@@ -91,7 +91,10 @@ async function mountSelect<T>(
|
||||
initial: DialogSelectOption<T>[],
|
||||
current?: T,
|
||||
focusCurrent?: boolean,
|
||||
select?: Pick<DialogSelectProps<T>, "flat" | "ref" | "onFilter" | "renderFilter" | "onCancel" | "focusTarget">,
|
||||
select?: Pick<
|
||||
DialogSelectProps<T>,
|
||||
"flat" | "ref" | "onFilter" | "renderFilter" | "onCancel" | "focusTarget" | "search"
|
||||
>,
|
||||
) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
@@ -522,6 +525,27 @@ test("keeps the current option selected when options reorder", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("custom search follows a current change batched with reordered results", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const options = ["alpha", "beta", "gamma"].map((value) => ({ title: value, value }))
|
||||
const [results, setResults] = createSignal(options)
|
||||
const select = await mountSelect(tmp.path, options, "alpha", undefined, { search: () => results() })
|
||||
|
||||
try {
|
||||
batch(() => {
|
||||
select.replaceCurrent("gamma")
|
||||
setResults(options.toReversed())
|
||||
})
|
||||
await select.app.waitForFrame((frame) => frame.indexOf("gamma") < frame.indexOf("alpha"))
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => select.selected.length === 1)
|
||||
|
||||
expect(select.selected).toEqual(["gamma"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, 0, "", null, "current", undefined])("focuses current %p when it changes", async (current) => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mountSelect<string | typeof current>(
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { InputRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { Dialog } from "@opencode/plugin/tui/context"
|
||||
import { createSignal } from "solid-js"
|
||||
import { ConfigProvider } from "../src/config"
|
||||
import { Keymap } from "../src/context/keymap"
|
||||
import { ThemeProvider } from "../src/context/theme"
|
||||
import { createDialogApi } from "../src/plugin/api"
|
||||
import { DialogProvider, useDialog } from "../src/ui/dialog"
|
||||
import { ToastProvider } from "../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "./fixture/fixture"
|
||||
import { TestTuiContexts } from "./fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
|
||||
async function mount(root: string) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const dialog = Promise.withResolvers<Dialog>()
|
||||
|
||||
function Fixture() {
|
||||
dialog.resolve(createDialogApi(useDialog(), (render) => render()))
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 80, height: 24, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
return { app, dialog: await dialog.promise }
|
||||
}
|
||||
|
||||
async function opened(app: Awaited<ReturnType<typeof mount>>["app"], title: string) {
|
||||
await app.waitForFrame((frame) => frame.includes(title))
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
}
|
||||
|
||||
function rows(frame: string, titles: string[]) {
|
||||
return titles.toSorted((left, right) => frame.indexOf(left) - frame.indexOf(right))
|
||||
}
|
||||
|
||||
test("search orders enabled options across categories and receives every query", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const calls: [string, string[]][] = []
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Projects",
|
||||
options: [
|
||||
{ title: "Alpha", value: "alpha", category: "Recent" },
|
||||
{ title: "Beta", value: "beta", category: "Pinned" },
|
||||
{ title: "Gamma", value: "gamma", category: "Recent" },
|
||||
{ title: "Delta", value: "delta", category: "Pinned", disabled: true },
|
||||
],
|
||||
search: (query, options) => {
|
||||
calls.push([query, options.map((option) => option.value)])
|
||||
return options
|
||||
.filter((option) => option.title.toLowerCase().includes(query))
|
||||
.toSorted((left, right) => right.title.localeCompare(left.title))
|
||||
},
|
||||
})
|
||||
await opened(select.app, "Projects")
|
||||
const frame = select.app.captureCharFrame()
|
||||
expect(rows(frame, ["Alpha", "Beta", "Gamma"])).toEqual(["Gamma", "Beta", "Alpha"])
|
||||
expect(frame).not.toContain("Delta")
|
||||
expect(frame).toMatch(/Gamma +Recent/)
|
||||
expect(frame).toMatch(/Beta +Pinned/)
|
||||
|
||||
await select.app.mockInput.typeText("l")
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Gamma"))
|
||||
expect(select.app.captureCharFrame()).not.toContain("Delta")
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("alpha")
|
||||
expect(calls[0]).toEqual(["", ["alpha", "beta", "gamma"]])
|
||||
expect(calls.at(-1)).toEqual(["l", ["alpha", "beta", "gamma"]])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("search keeps the selected value when its results reorder", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const [reversed, setReversed] = createSignal(false)
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Ranked",
|
||||
options: ["First", "Second", "Third", "Fourth"].map((title) => ({ title, value: title.toLowerCase() })),
|
||||
search: (_, options) => (reversed() ? options.toReversed() : options),
|
||||
})
|
||||
await opened(select.app, "Ranked")
|
||||
select.app.mockInput.pressArrow("down")
|
||||
setReversed(true)
|
||||
await select.app.waitForFrame((frame) => frame.indexOf("Fourth") < frame.indexOf("First"))
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("second")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("search keeps a query's first result when the same query reorders", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const [reversed, setReversed] = createSignal(false)
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Ranked",
|
||||
options: ["First", "Second", "Third", "Fourth"].map((title) => ({ title, value: title.toLowerCase() })),
|
||||
search: (query, options) => {
|
||||
const matches = options.filter((option) => option.value.includes(query))
|
||||
return reversed() ? matches.toReversed() : matches
|
||||
},
|
||||
})
|
||||
await opened(select.app, "Ranked")
|
||||
await select.app.mockInput.typeText("r")
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Second"))
|
||||
setReversed(true)
|
||||
await select.app.waitForFrame((frame) => frame.indexOf("Fourth") < frame.indexOf("First"))
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("first")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("search scrolls a moved or replaced selection into view", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const items = Array.from({ length: 30 }, (_, index) => {
|
||||
const title = `Item ${String(index).padStart(2, "0")}`
|
||||
return { title, value: title }
|
||||
})
|
||||
const [results, setResults] = createSignal(items)
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Long",
|
||||
options: items,
|
||||
search: () => results(),
|
||||
})
|
||||
await opened(select.app, "Long")
|
||||
expect(select.app.captureCharFrame()).not.toContain("Item 29")
|
||||
|
||||
setResults(items.toReversed())
|
||||
await select.app.waitForFrame((frame) => frame.includes("Item 00") && !frame.includes("Item 29"))
|
||||
setResults(items.slice(1).toReversed())
|
||||
await select.app.waitForFrame((frame) => frame.includes("Item 01") && !frame.includes("Item 00"))
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("Item 01")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("search ignores disabled records returned by the callback", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const hidden = { title: "Hidden", value: "hidden", disabled: true }
|
||||
const triggered: string[] = []
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Guarded",
|
||||
options: [{ title: "Alpha", value: "alpha" }],
|
||||
search: (_, options) => [hidden, ...options],
|
||||
actions: [{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => triggered.push(value) }],
|
||||
})
|
||||
await opened(select.app, "Guarded")
|
||||
expect(select.app.captureCharFrame()).not.toContain("Hidden")
|
||||
select.app.mockInput.pressKey("o", { ctrl: true })
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("alpha")
|
||||
expect(triggered).toEqual(["alpha"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("search returns to the first row when the query clears without a current value", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Cleared",
|
||||
options: ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() })),
|
||||
search: (query, options) => options.filter((option) => option.value.includes(query)),
|
||||
})
|
||||
await opened(select.app, "Cleared")
|
||||
await select.app.mockInput.typeText("g")
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Beta"))
|
||||
select.app.mockInput.pressBackspace()
|
||||
await select.app.waitForFrame((frame) => frame.includes("Beta"))
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("alpha")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("search selects the current value once it arrives in the results", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const options = ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() }))
|
||||
const [results, setResults] = createSignal<typeof options>([])
|
||||
try {
|
||||
const chosen = select.dialog.select({ title: "Loading", options, current: "gamma", search: () => results() })
|
||||
await opened(select.app, "Loading")
|
||||
setResults(options)
|
||||
await select.app.waitForFrame((frame) => frame.includes("Gamma"))
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("gamma")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("search returns to the current value when the query clears", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Current",
|
||||
options: ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() })),
|
||||
current: "beta",
|
||||
search: (query, options) => options.filter((option) => option.value.includes(query)),
|
||||
})
|
||||
await opened(select.app, "Current")
|
||||
await select.app.mockInput.typeText("g")
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Beta"))
|
||||
select.app.mockInput.pressBackspace()
|
||||
await select.app.waitForFrame((frame) => frame.includes("Beta"))
|
||||
select.app.mockInput.pressEnter()
|
||||
|
||||
expect(await chosen).toBe("beta")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
[false, true],
|
||||
[0, 1],
|
||||
["", "other"],
|
||||
])("actions deliver the selected value %p from keyboard, footer click, and footer focus", async (value, other) => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const triggered: unknown[] = []
|
||||
let settled = false
|
||||
try {
|
||||
const chosen = select.dialog.select<typeof value | typeof other>({
|
||||
title: "Values",
|
||||
options: [
|
||||
{ title: "Chosen", value },
|
||||
{ title: "Other", value: other },
|
||||
],
|
||||
actions: [{ bind: "ctrl+o", title: "Inspect", onTrigger: (selected) => triggered.push(selected) }],
|
||||
})
|
||||
void chosen.then(() => (settled = true))
|
||||
await opened(select.app, "Values")
|
||||
await select.app.waitForFrame((frame) => frame.includes("Inspect ctrl+o"))
|
||||
|
||||
select.app.mockInput.pressKey("o", { ctrl: true })
|
||||
const frame = select.app.captureCharFrame().split("\n")
|
||||
const row = frame.findIndex((line) => line.includes("Inspect ctrl+o"))
|
||||
await select.app.mockMouse.click(frame[row]!.indexOf("Inspect") + 1, row)
|
||||
select.app.mockInput.pressTab()
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => triggered.length === 3)
|
||||
await Bun.sleep(10)
|
||||
|
||||
expect(triggered).toEqual([value, value, value])
|
||||
expect(settled).toBe(false)
|
||||
expect(select.app.captureCharFrame()).toContain("Values")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("actions receive object values and unbind when the dialog closes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const project = { id: "acme" }
|
||||
const triggered: { id: string }[] = []
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Objects",
|
||||
options: [{ title: "Acme", value: project }],
|
||||
actions: [{ bind: "ctrl+o", title: "Inspect", onTrigger: (selected) => triggered.push(selected) }],
|
||||
})
|
||||
await opened(select.app, "Objects")
|
||||
select.app.mockInput.pressKey("o", { ctrl: true })
|
||||
select.app.mockInput.pressEscape()
|
||||
|
||||
expect(await chosen).toBeUndefined()
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Objects"))
|
||||
select.app.mockInput.pressKey("o", { ctrl: true })
|
||||
expect(triggered).toEqual([project])
|
||||
expect(triggered[0]).toBe(project)
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("only no-selection actions run without results", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const triggered: string[] = []
|
||||
try {
|
||||
void select.dialog.select({
|
||||
title: "Empty",
|
||||
options: [{ title: "Alpha", value: "alpha" }],
|
||||
search: () => [],
|
||||
actions: [
|
||||
{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => triggered.push(value) },
|
||||
{ bind: "ctrl+a", title: "New", side: "right", selection: "none", onTrigger: () => triggered.push("new") },
|
||||
],
|
||||
})
|
||||
await opened(select.app, "Empty")
|
||||
await select.app.waitForFrame((frame) => frame.includes("New ctrl+a"))
|
||||
select.app.mockInput.pressKey("o", { ctrl: true })
|
||||
select.app.mockInput.pressKey("a", { ctrl: true })
|
||||
|
||||
expect(triggered).toEqual(["new"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("an action that opens a replacement dialog settles the select and keeps the replacement", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mount(tmp.path)
|
||||
const triggered: string[] = []
|
||||
try {
|
||||
const chosen = select.dialog.select({
|
||||
title: "Projects",
|
||||
options: [{ title: "Alpha", value: "alpha" }],
|
||||
actions: [
|
||||
{
|
||||
bind: "ctrl+o",
|
||||
title: "Rename",
|
||||
onTrigger: (value) => {
|
||||
triggered.push(value)
|
||||
void select.dialog.prompt({ title: `Rename ${value}` })
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await opened(select.app, "Projects")
|
||||
select.app.mockInput.pressKey("o", { ctrl: true })
|
||||
|
||||
expect(await chosen).toBeUndefined()
|
||||
await select.app.waitForFrame((frame) => frame.includes("Rename alpha"))
|
||||
select.app.mockInput.pressKey("o", { ctrl: true })
|
||||
await Bun.sleep(10)
|
||||
|
||||
expect(triggered).toEqual(["alpha"])
|
||||
expect(select.app.captureCharFrame()).toContain("Rename alpha")
|
||||
expect(select.app.captureCharFrame()).not.toContain("Projects")
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -333,6 +333,44 @@ const mode = await context.ui.dialog.select({
|
||||
})
|
||||
```
|
||||
|
||||
### Select search
|
||||
|
||||
Pass `search` to own matching and result order. It runs for every query, including the empty one, and receives only enabled options. Results are listed in the returned order, with each category shown inline.
|
||||
|
||||
```ts
|
||||
const project = await context.ui.dialog.select({
|
||||
title: "Projects",
|
||||
options: [
|
||||
{ title: "Alpha", value: "alpha", category: "Recent" },
|
||||
{ title: "Beta", value: "beta", category: "Pinned" },
|
||||
],
|
||||
search: (query, options) =>
|
||||
options
|
||||
.filter((option) => option.title.toLowerCase().includes(query.toLowerCase()))
|
||||
.toSorted((left, right) => right.title.localeCompare(left.title)),
|
||||
})
|
||||
```
|
||||
|
||||
### Select actions
|
||||
|
||||
Actions appear in the footer and run from their keyboard binding, a click, or Tab focus followed by Enter. By default they receive the selected value and are unavailable without a selection. Use `selection: "none"` for actions that need no selection.
|
||||
|
||||
```ts
|
||||
await context.ui.dialog.select({
|
||||
title: "Projects",
|
||||
options: [{ title: "Alpha", value: "alpha" }],
|
||||
actions: [
|
||||
{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
|
||||
{ bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
- An action does not close the dialog or settle the promise.
|
||||
- Enter or clicking an option resolves the selected value.
|
||||
- Escape, `clear()`, or showing another dialog resolves `undefined`.
|
||||
- Do not bind `ctrl+n` or `ctrl+p`. The dialog reserves them for navigation, and its own bindings take precedence.
|
||||
|
||||
Custom JSX dialogs can set their size and close themselves.
|
||||
|
||||
```tsx
|
||||
|
||||
Reference in new issue
Block a user