mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
feat(tui): jump between open menu sections (#42061)
This commit is contained in:
@@ -151,6 +151,7 @@ export function DialogOpen() {
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
sectionNavigation={true}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface DialogSelectProps<T> {
|
||||
bindings?: readonly KeymapCommand[]
|
||||
current?: T
|
||||
focusCurrent?: boolean
|
||||
sectionNavigation?: boolean
|
||||
}
|
||||
|
||||
type DialogSelectActionBase<T> = {
|
||||
@@ -327,6 +328,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
|
||||
}
|
||||
|
||||
function moveSection(direction: 1 | -1) {
|
||||
if (props.locked) return
|
||||
const sections = grouped().filter(([_, options]) => options.length > 0)
|
||||
if (sections.length === 0) return
|
||||
const current = sections.findIndex(([category]) => category === selected()?.category)
|
||||
const section = sections[(current + direction + sections.length) % sections.length]
|
||||
moveTo(flat().indexOf(section[1][0]), true)
|
||||
}
|
||||
|
||||
function moveTo(next: number, center = false, preserve = true) {
|
||||
setFocusedAction(undefined)
|
||||
setStore("selected", next)
|
||||
@@ -488,6 +498,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
]
|
||||
: []),
|
||||
...(props.bindings ?? []),
|
||||
...(props.sectionNavigation
|
||||
? [
|
||||
{
|
||||
bind: "alt+up",
|
||||
title: "Previous section",
|
||||
group: "Dialog",
|
||||
run: () => moveSection(-1),
|
||||
},
|
||||
{
|
||||
bind: "alt+down",
|
||||
title: "Next section",
|
||||
group: "Dialog",
|
||||
run: () => moveSection(1),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,6 +186,94 @@ test("preserves a moved project when sessions arrive", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("option arrows jump between sections", async () => {
|
||||
const handler: FetchHandler = (url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_recent",
|
||||
projectID: "proj_recent",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Recent session",
|
||||
location: { directory: "/tmp/opencode/recent" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_recent",
|
||||
canonical: "/tmp/opencode/recent",
|
||||
name: "Recent project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
return undefined
|
||||
}
|
||||
|
||||
const next = await renderOpen(handler)
|
||||
try {
|
||||
await next.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
|
||||
next.app.mockInput.pressArrow("down", { meta: true })
|
||||
next.app.mockInput.pressEnter()
|
||||
await next.app.waitFor(() => next.route.data.type === "home")
|
||||
expect(next.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
|
||||
} finally {
|
||||
await next.dispose()
|
||||
}
|
||||
|
||||
const previous = await renderOpen(handler)
|
||||
try {
|
||||
await previous.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
|
||||
previous.app.mockInput.pressArrow("up", { meta: true })
|
||||
previous.app.mockInput.pressEnter()
|
||||
await previous.app.waitFor(() => previous.route.data.type === "home")
|
||||
expect(previous.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
|
||||
} finally {
|
||||
await previous.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("option arrows stay in the only visible section", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_effect",
|
||||
canonical: "/tmp/effect",
|
||||
name: "Effect",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_opencode",
|
||||
canonical: "/tmp/opencode",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && frame.includes("OpenCode"))
|
||||
await fixture.app.mockInput.typeText("Effect")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && !frame.includes("OpenCode"))
|
||||
fixture.app.mockInput.pressArrow("down", { meta: true })
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/effect" } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderOpen(
|
||||
handler: FetchHandler,
|
||||
beforeOpen?: (contexts: {
|
||||
|
||||
Reference in New Issue
Block a user