diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index f734f1b3cb8..4911189880c 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -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() diff --git a/packages/app/src/runtime/plugin/side-region.tsx b/packages/app/src/runtime/plugin/side-region.tsx index 7b04bb5c7e7..ce39344aa46 100644 --- a/packages/app/src/runtime/plugin/side-region.tsx +++ b/packages/app/src/runtime/plugin/side-region.tsx @@ -346,17 +346,7 @@ export function PaneTabs(props: { > - } - variant="ghost-muted" - size="large" - onClick={add} - aria-label={language.t("session.tab.add")} - /> - - } + fallback={} > {(key) => ( 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 ( @@ -522,56 +505,119 @@ function AddMenu(props: { items: readonly SessionPanelMenuEntry[]; onChoose: () onPointerDown={(event: PointerEvent) => event.preventDefault()} /> - { - const item = chosen - - if (!item) return - chosen = undefined - setTimeout(() => { - if (document.activeElement instanceof HTMLElement) document.activeElement.blur() - props.onChoose() - item.run() - }) - }} - > - - {(key) => ( - - {(item) => { - const keybind = createMemo(() => { - const bind = item().keybind - - return bind ? command.keybindParts(bind) : [] - }) - - return ( - (chosen = item())} - shortcut={ - 0}> - - - } - > -
- {(icon) => } - {item().title} -
-
- ) - }} -
- )} -
-
+
) } +/** "+" 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. */} + + } + 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")} + /> + + setMenu("open", open)} + modal={false} + placement="bottom-start" + gutter={4} + getAnchorRect={() => ({ x: menu.x, y: menu.y, width: 0, height: 0 })} + > + + + + + + ) +} + +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 ( + { + const item = chosen + + if (!item) return + chosen = undefined + setTimeout(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + props.onChoose() + item.run() + }) + }} + > + + {(key) => ( + + {(item) => { + const keybind = createMemo(() => { + const bind = item().keybind + + return bind ? command.keybindParts(bind) : [] + }) + + return ( + (chosen = item())} + shortcut={ + 0}> + + + } + > +
+ {(icon) => } + {item().title} +
+
+ ) + }} +
+ )} +
+
+ ) +} + /** The + menu items as rows, then the session's recently closed tabs, in the new tab view. */ function Launchers(props: { items: readonly SessionPanelMenuEntry[]