feat(app): open the side panel launchers from a right-click on +

This commit is contained in:
iamdavidhill
2026-10-31 07:13:30 +00:00
Unverified
parent 956e41fd17
commit 636720a898
2 changed files with 142 additions and 62 deletions
@@ -166,6 +166,40 @@ test("tabs in a narrow side strip shrink to keep their close buttons, and a tab'
await expect(side.getByRole("tab", { name: "b.md", exact: true })).toHaveCount(0)
})
test("right-clicking + lists the launchers as a menu instead of opening a New tab", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/AddMenu"
const key = `local\u0000${base64Encode(directory)}/ses_addmenu`
await openSession(page, {
name: "AddMenu",
fileList: () => [],
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
seed: {
panes: { ses_addmenu: { review: true } },
storage: {
"opencode.global.dat:layout": {
sessionTabs: { [key]: { all: ["file://README.md"], active: "file://README.md" } },
},
},
},
})
const side = page.locator('[data-pane-content="side"]')
const add = side.getByRole("button", { name: "Add tab", exact: true })
const newTab = side.getByRole("tab", { name: "New tab", exact: true })
await expect(side.getByRole("tab", { name: "README.md", exact: true })).toHaveAttribute("aria-selected", "true")
await add.click({ button: "right" })
await page.getByRole("menuitem", { name: /^Review/ }).click()
await expect(side.getByRole("tab", { name: /^Review/ })).toHaveAttribute("aria-selected", "true")
await expect(newTab).toHaveCount(0)
// A plain click still opens the full New tab view.
await add.click()
await expect(newTab).toHaveAttribute("aria-selected", "true")
})
async function expectTreeSentinel(page: Page) {
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
await expect(tree.getByRole("treeitem", { name: "action.yml" })).toBeVisible()
+108 -62
View File
@@ -346,17 +346,7 @@ export function PaneTabs(props: {
>
<Show
when={modifier}
fallback={
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={add}
aria-label={language.t("session.tab.add")}
/>
</Tooltip>
}
fallback={<NewTabButtonWithMenu items={launchers()} onChoose={target} onNewTab={add} />}
>
{(key) => (
<NewTabButton
@@ -500,13 +490,6 @@ function AddButton(props: { item: SessionPanelMenuItem; onChoose: () => void }):
function AddMenu(props: { items: readonly SessionPanelMenuEntry[]; onChoose: () => void }): JSX.Element {
const language = useLanguage()
const command = useCommand()
const keys = createMemo(() => props.items.map((entry) => entry.key), [], { equals: same })
const byKey = createMemo(() => new Map(props.items.map((entry) => [entry.key, entry.item])))
// A chosen item runs once the closing menu has returned focus to "+", and the tab strip has passed it on to the
// selected tab. Releasing that focus first lets the item move it where its tab needs it, e.g. into a new terminal,
// without the strip taking it back for the tab it remembers.
let chosen: SessionPanelMenuItem | undefined
return (
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
@@ -522,56 +505,119 @@ function AddMenu(props: { items: readonly SessionPanelMenuEntry[]; onChoose: ()
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
<Menu.Portal>
<Menu.Content
onCloseAutoFocus={() => {
const item = chosen
if (!item) return
chosen = undefined
setTimeout(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
props.onChoose()
item.run()
})
}}
>
<For each={keys()}>
{(key) => (
<Show when={byKey().get(key)}>
{(item) => {
const keybind = createMemo(() => {
const bind = item().keybind
return bind ? command.keybindParts(bind) : []
})
return (
<Menu.Item
class="!gap-6"
onSelect={() => (chosen = item())}
shortcut={
<Show when={keybind().length > 0}>
<Keybind keys={keybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Show when={item().icon}>{(icon) => <Icon name={icon()} size="small" />}</Show>
<span>{item().title}</span>
</div>
</Menu.Item>
)
}}
</Show>
)}
</For>
</Menu.Content>
<AddMenuContent items={props.items} onChoose={props.onChoose} />
</Menu.Portal>
</Menu>
</Tooltip>
)
}
/** "+" opens the new tab view; a right click lists the same launchers as a quick menu under it instead. */
function NewTabButtonWithMenu(props: {
items: readonly SessionPanelMenuEntry[]
onChoose: () => void
onNewTab: () => void
}): JSX.Element {
const language = useLanguage()
const [menu, setMenu] = createStore({ open: false, x: 0, y: 0 })
return (
<>
{/* The tooltip would sit on top of the menu's first item. */}
<Tooltip
value={language.t("session.tab.add")}
placement="bottom"
class="flex items-center"
forceOpen={menu.open ? false : undefined}
>
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={props.onNewTab}
onContextMenu={(event: MouseEvent) => {
if (!(event.currentTarget instanceof HTMLElement)) return
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
setMenu({ open: true, x: rect.left, y: rect.bottom })
}}
aria-label={language.t("session.tab.add")}
/>
</Tooltip>
<Menu
appearance="standard"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
modal={false}
placement="bottom-start"
gutter={4}
getAnchorRect={() => ({ x: menu.x, y: menu.y, width: 0, height: 0 })}
>
<Menu.Portal>
<AddMenuContent items={props.items} onChoose={props.onChoose} />
</Menu.Portal>
</Menu>
</>
)
}
function AddMenuContent(props: { items: readonly SessionPanelMenuEntry[]; onChoose: () => void }): JSX.Element {
const command = useCommand()
const keys = createMemo(() => props.items.map((entry) => entry.key), [], { equals: same })
const byKey = createMemo(() => new Map(props.items.map((entry) => [entry.key, entry.item])))
// A chosen item runs once the closing menu has returned focus to "+", and the tab strip has passed it on to the
// selected tab. Releasing that focus first lets the item move it where its tab needs it, e.g. into a new terminal,
// without the strip taking it back for the tab it remembers.
let chosen: SessionPanelMenuItem | undefined
return (
<Menu.Content
onCloseAutoFocus={() => {
const item = chosen
if (!item) return
chosen = undefined
setTimeout(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
props.onChoose()
item.run()
})
}}
>
<For each={keys()}>
{(key) => (
<Show when={byKey().get(key)}>
{(item) => {
const keybind = createMemo(() => {
const bind = item().keybind
return bind ? command.keybindParts(bind) : []
})
return (
<Menu.Item
class="!gap-6"
onSelect={() => (chosen = item())}
shortcut={
<Show when={keybind().length > 0}>
<Keybind keys={keybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Show when={item().icon}>{(icon) => <Icon name={icon()} size="small" />}</Show>
<span>{item().title}</span>
</div>
</Menu.Item>
)
}}
</Show>
)}
</For>
</Menu.Content>
)
}
/** The + menu items as rows, then the session's recently closed tabs, in the new tab view. */
function Launchers(props: {
items: readonly SessionPanelMenuEntry[]