mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): cycle side-panel tabs when side panel is focused (#54899)
This commit is contained in:
committed by
GitHub
Unverified
parent
84e095526c
commit
573fd296d0
@@ -533,6 +533,70 @@ test("each session tab shows its own file tab after a switch to a session in ano
|
||||
}
|
||||
})
|
||||
|
||||
test("tab shortcuts cycle side-panel tabs when the side panel is focused with multiple tabs", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const directory = "C:/OpenCode/SidePanelTabs"
|
||||
const alpha = { id: "ses_side_tabs_alpha", title: "Side tabs alpha" }
|
||||
const beta = { id: "ses_side_tabs_beta", title: "Side tabs beta" }
|
||||
await openSession(page, {
|
||||
name: "SidePanelTabs",
|
||||
sessions: [alpha, beta],
|
||||
fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
seed: { panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } } },
|
||||
})
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const reviewTab = panel.getByRole("tab", { name: "Review", exact: true })
|
||||
const fileTab = panel.getByRole("tab", { name: "README.md", exact: true })
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await panel.getByRole("button", { name: "README.md", exact: true }).click()
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
|
||||
// Focus outside the side panel still cycles session tabs even when multiple side-panel tabs are open.
|
||||
await composer.click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expectPath(page, sessionHref(beta.id))
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Focus inside the side panel cycles the drawn side-panel tabs and keeps focus inside the side panel.
|
||||
await panel.getByText("contents:README.md", { exact: true }).click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("Control+Shift+Tab")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowRight")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
// Once only one side-panel tab remains, the shortcut returns to cycling session tabs.
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(fileTab).toHaveCount(0)
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await reviewTab.click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expectPath(page, sessionHref(beta.id))
|
||||
})
|
||||
|
||||
// Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform.
|
||||
test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => {
|
||||
await mockWorkspace(page, { name: "WindowsMenu", sessions: [] })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
@@ -75,17 +75,30 @@ export function SideRegion(props: {
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
)
|
||||
|
||||
let root: HTMLElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
let selectionEvent: Event | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (open()) return
|
||||
const active = document.activeElement
|
||||
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
|
||||
if (!root?.contains(active)) return
|
||||
active.blur()
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !!params.id}>
|
||||
<aside
|
||||
ref={root}
|
||||
id="review-panel"
|
||||
tabIndex={-1}
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
aria-hidden={!open()}
|
||||
inert={!open()}
|
||||
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
|
||||
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)] outline-none"
|
||||
classList={{
|
||||
"h-full shrink-0": !props.stacked,
|
||||
"h-full min-h-0": props.stacked,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCommand, type CommandOption } from "@/shell/commands/command"
|
||||
import { adjacentTabKey } from "@/shell/titlebar/tab-order"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
|
||||
import { useFile, selectionFromLines, type FileSelection } from "@/workspaces/files/model"
|
||||
@@ -80,11 +81,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
return entry && !entry.tab.pinned ? entry.key : undefined
|
||||
}
|
||||
|
||||
const cycleSideTabs = () => actions.session.layout.view().side.opened() && actions.region.keys().length > 1
|
||||
|
||||
// Focus inside an extension command's scope belongs to that extension, which binds its own shortcuts there.
|
||||
const extensionScoped = (target: EventTarget | null) =>
|
||||
target instanceof Element &&
|
||||
host.items(Command).some((item) => !!item.value.scope && !!target.closest(item.value.scope))
|
||||
|
||||
const sideFocused = (target: EventTarget | null) => {
|
||||
const element =
|
||||
target instanceof Element && target !== document.body && target !== document.documentElement
|
||||
? target
|
||||
: document.activeElement
|
||||
|
||||
return element instanceof Element && !!element.closest("#review-panel") && !extensionScoped(element)
|
||||
}
|
||||
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
|
||||
@@ -203,6 +215,28 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (tab) actions.region.close(tab)
|
||||
}
|
||||
|
||||
const selectAdjacentSideTab = (offset: -1 | 1) => {
|
||||
const key = adjacentTabKey(actions.region.keys(), actions.region.active(), offset)
|
||||
|
||||
if (!key) return
|
||||
|
||||
actions.region.select(key)
|
||||
|
||||
const panel = document.getElementById("review-panel")
|
||||
|
||||
if (!panel) return
|
||||
|
||||
const active = document.activeElement
|
||||
|
||||
if (
|
||||
active instanceof HTMLElement &&
|
||||
panel.contains(active) &&
|
||||
!active.closest('[role="tab"][aria-selected="false"], [inert], .hidden')
|
||||
)
|
||||
return
|
||||
;(panel.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? panel).focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
const addSelection = () => {
|
||||
const path = activeFile()
|
||||
|
||||
@@ -352,6 +386,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
|
||||
const fileCmds = () => {
|
||||
const tab = closableTab()
|
||||
const cycle = cycleSideTabs()
|
||||
|
||||
return [
|
||||
fileCommand({
|
||||
@@ -370,6 +405,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
when: (event) => !extensionScoped(event.target),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
cycle &&
|
||||
fileCommand({
|
||||
id: "file.tab.prev",
|
||||
title: "",
|
||||
keybind: settings.keybinds.get("tab.prev") ?? "mod+option+ArrowLeft,ctrl+shift+tab",
|
||||
hidden: true,
|
||||
when: (event) => sideFocused(event.target),
|
||||
onSelect: () => selectAdjacentSideTab(-1),
|
||||
}),
|
||||
cycle &&
|
||||
fileCommand({
|
||||
id: "file.tab.next",
|
||||
title: "",
|
||||
keybind: settings.keybinds.get("tab.next") ?? "mod+option+ArrowRight,ctrl+tab",
|
||||
hidden: true,
|
||||
when: (event) => sideFocused(event.target),
|
||||
onSelect: () => selectAdjacentSideTab(1),
|
||||
}),
|
||||
].filter((v) => !!v)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user