mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
feat(app): open the side panel launchers from a right-click on +
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user