mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 01:31:22 +03:00
fix(app): keep new tab actions pane-local (#58452)
This commit is contained in:
committed by
GitHub
Unverified
parent
b7a2cfc138
commit
4572b5d054
@@ -127,6 +127,69 @@ test("review content stays aligned with the side region in both directions", asy
|
||||
await expectTreeSentinel(page)
|
||||
})
|
||||
|
||||
test("new tab selection and closing stay in the focused pane", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const directory = "C:/OpenCode/PaneNewTabs"
|
||||
const key = `local\u0000${base64Encode(directory)}/ses_panenewtabs`
|
||||
|
||||
await openSession(page, {
|
||||
name: "PaneNewTabs",
|
||||
fileList: () => [],
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
seed: {
|
||||
panes: { ses_panenewtabs: { review: true } },
|
||||
storage: {
|
||||
"opencode.global.dat:layout": {
|
||||
sessionTabs: {
|
||||
[key]: {
|
||||
all: ["file://README.md", "file://notes.md"],
|
||||
active: "file://README.md",
|
||||
panes: { "file://notes.md": "split" },
|
||||
selected: { side: "file://README.md", split: "file://notes.md" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const side = page.locator('[data-pane-content="side"]')
|
||||
const split = page.locator('[data-pane-content="split"]')
|
||||
const mod = (await page.evaluate(() => /Mac|iPhone|iPad/.test(navigator.platform))) ? "Meta" : "Control"
|
||||
const sideTabs = side.getByRole("tab", { name: "New tab", exact: true })
|
||||
const splitTabs = split.getByRole("tab", { name: "New tab", exact: true })
|
||||
await expect(side.getByRole("tab", { name: "README.md", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(split.getByRole("tab", { name: "notes.md", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await side.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await expect(sideTabs).toHaveCount(1)
|
||||
await side.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await expect(sideTabs).toHaveCount(2)
|
||||
// Pick the older New tab, so a fallback to the newest one cannot pass unnoticed.
|
||||
await sideTabs.nth(0).click()
|
||||
await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await split.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await expect(splitTabs).toHaveAttribute("aria-selected", "true")
|
||||
await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
await expect(sideTabs.nth(1)).toHaveAttribute("aria-selected", "false")
|
||||
|
||||
await splitTabs.click()
|
||||
await splitTabs.press(`${mod}+w`)
|
||||
await expect(splitTabs).toHaveCount(0)
|
||||
await expect(split.getByRole("tab", { name: "notes.md", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(sideTabs).toHaveCount(2)
|
||||
await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// With focus on the region itself, close falls back to the active pane, not another pane's New tab.
|
||||
await split.getByRole("tab", { name: "notes.md", exact: true }).click()
|
||||
await page.locator("#review-panel").focus()
|
||||
await page.keyboard.press(`${mod}+w`)
|
||||
await expect(split).toHaveCount(0)
|
||||
await expect(sideTabs).toHaveCount(2)
|
||||
await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
// A DOM mark survives only while the same node stays mounted.
|
||||
function mark(locator: Locator) {
|
||||
return locator.evaluate((element) => element.setAttribute("data-e2e-mounted", "original"))
|
||||
@@ -202,7 +265,7 @@ test("the new tab view lists recently closed tabs, expands to the rest, and keep
|
||||
await readme.locator("..").getByRole("button", { name: "Close tab" }).click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await toggle.click()
|
||||
await expect(panel.getByRole("tab")).toHaveCount(0)
|
||||
await expect(panel.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(rows.first()).toContainText("README.md")
|
||||
|
||||
// The list survives a reload, and reopening a row brings its tab back and takes it off the list.
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
mergeProps,
|
||||
on,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import {
|
||||
@@ -313,21 +313,24 @@ export function createRegion(input: {
|
||||
// and selects itself replaces it. A view stops being live once the selection moves off what it opened over.
|
||||
type NewTab = { id: string; pane: SessionPane; session: string; over: string | undefined }
|
||||
|
||||
const [adding, setAdding] = createSignal<NewTab[]>([])
|
||||
const [chosen, setChosen] = createSignal<string>()
|
||||
const [state, setState] = createStore<{
|
||||
adding: NewTab[]
|
||||
chosen: Partial<Record<SessionPane, string>>
|
||||
}>({ adding: [], chosen: {} })
|
||||
|
||||
let serial = 0
|
||||
|
||||
const live = (value: NewTab) => value.session === input.view().key && value.over === panes[value.pane].active()
|
||||
const newTabs = (pane: SessionPane) => adding().filter((value) => value.pane === pane && live(value))
|
||||
const newTabs = (pane: SessionPane) => state.adding.filter((value) => value.pane === pane && live(value))
|
||||
|
||||
const shownNewTab = (pane: SessionPane) => {
|
||||
const list = newTabs(pane)
|
||||
|
||||
return list.find((value) => value.id === chosen()) ?? list.at(-1)
|
||||
return list.find((value) => value.id === state.chosen[pane]) ?? list.at(-1)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (adding().some((value) => !live(value))) setAdding((list) => list.filter(live))
|
||||
if (state.adding.some((value) => !live(value))) setState("adding", (list) => list.filter(live))
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -363,31 +366,27 @@ export function createRegion(input: {
|
||||
/** The new tab view a pane shows: the selected one, else the newest. */
|
||||
shownNewTab,
|
||||
newTab: (pane: SessionPane) => shownNewTab(pane) !== undefined,
|
||||
selectNewTab(id: string) {
|
||||
setChosen(id)
|
||||
selectNewTab(pane: SessionPane, id: string) {
|
||||
setState("chosen", pane, id)
|
||||
},
|
||||
/** Adds a new tab view in a pane, by default the one holding the selected tab, and shows it. */
|
||||
openNewTab(pane: SessionPane = activePane()) {
|
||||
const id = `new:${++serial}`
|
||||
|
||||
setAdding((list) => [...list, { id, pane, session: input.view().key, over: panes[pane].active() }])
|
||||
setChosen(id)
|
||||
batch(() => {
|
||||
setState("adding", (list) => [...list, { id, pane, session: input.view().key, over: panes[pane].active() }])
|
||||
setState("chosen", pane, id)
|
||||
})
|
||||
},
|
||||
dismissNewTab(id: string) {
|
||||
setAdding((list) => list.filter((value) => value.id !== id))
|
||||
setState("adding", (list) => list.filter((value) => value.id !== id))
|
||||
},
|
||||
/** Closes the shown new tab view, in one pane or wherever it is; returns whether one was showing. */
|
||||
closeNewTab(pane?: SessionPane) {
|
||||
const panesToCheck: SessionPane[] = pane ? [pane] : ["side", "split", "bottom"]
|
||||
|
||||
const value = panesToCheck.flatMap((id) => {
|
||||
const shown = shownNewTab(id)
|
||||
|
||||
return shown ? [shown] : []
|
||||
})[0]
|
||||
/** Closes the shown new tab view in the given or active pane; returns whether one was showing. */
|
||||
closeNewTab(pane: SessionPane = activePane()) {
|
||||
const value = shownNewTab(pane)
|
||||
|
||||
if (!value) return false
|
||||
setAdding((list) => list.filter((entry) => entry.id !== value.id))
|
||||
setState("adding", (list) => list.filter((entry) => entry.id !== value.id))
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -232,7 +232,7 @@ export function PaneTabs(props: {
|
||||
value={current()?.id ?? props.pane.active() ?? "empty"}
|
||||
onChange={(value) => {
|
||||
if (props.region.newTabs(props.pane.id).some((entry) => entry.id === value))
|
||||
return props.region.selectNewTab(value)
|
||||
return props.region.selectNewTab(props.pane.id, value)
|
||||
|
||||
// Kobalte selects the first tab while triggers register, including while the "+" menu's click is still
|
||||
// dispatching. Persist input events on a tab only; the region owns fallback selection.
|
||||
@@ -383,7 +383,7 @@ export function PaneTabs(props: {
|
||||
</Show>
|
||||
<Show when={props.placeholder && !current() && props.pane.active() === undefined}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
{/* With a new tab item, the new tab it opens is on its way. */}
|
||||
{/* After + runs a primary item, its tab owns the empty pane until it arrives. */}
|
||||
<Show when={!primary()}>
|
||||
<Show
|
||||
when={launchers().length > 0}
|
||||
|
||||
@@ -204,19 +204,21 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
)
|
||||
}
|
||||
|
||||
// In the pane holding focus; the region itself, or a key a native page handed over, means the selected tab's pane.
|
||||
const focusedPane = () => {
|
||||
const pane = document.activeElement?.closest("[data-pane-content]")?.getAttribute("data-pane-content")
|
||||
|
||||
return pane === "side" || pane === "split" ? pane : undefined
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
if (actions.region.closeNewTab()) return
|
||||
if (actions.region.closeNewTab(focusedPane())) return
|
||||
const tab = closableTab()
|
||||
|
||||
if (tab) actions.region.close(tab)
|
||||
}
|
||||
|
||||
// In the pane holding focus; the region itself, or a key a native page handed over, means the selected tab's pane.
|
||||
const openSideTab = () => {
|
||||
const pane = document.activeElement?.closest("[data-pane-content]")?.getAttribute("data-pane-content")
|
||||
|
||||
actions.region.openNewTab(pane === "side" || pane === "split" ? pane : undefined)
|
||||
}
|
||||
const openSideTab = () => actions.region.openNewTab(focusedPane())
|
||||
|
||||
const selectAdjacentSideTab = (offset: -1 | 1) => {
|
||||
const key = adjacentTabKey(actions.region.keys(), actions.region.active(), offset)
|
||||
|
||||
@@ -136,6 +136,7 @@ flowchart LR
|
||||
- All panel callbacks take one named input: `list({ session, screen, open })`, `render({ tab, session, screen })`, `focus({ tab, session, screen, restored })`, `enter({ tab, session, screen })`, `close({ tab, session, screen })`, `reopen({ tab, session, screen })`, `normalize({ id, session, screen })`. Session fields are getters; the screen is the constant owning screen. Render tab fields are live getters too; lifecycle callbacks receive their event's tab. Stored ids and `restored` describe that call. Do not destructure live getters. Every `session.*` slot gets a non-null `screen`; `window.bottom` does not.
|
||||
- Render effects can run before public publication too: change-list views call `Changes.watch(input.screen, source)`, not `ctx.screen.current()`, so their first demand is never lost.
|
||||
- `fallback: true` opts a tab into selection when the stored selection is missing or cannot be selected; false or omission opts out. The host takes the first eligible regular tab, then a `first` tab, then a pinned tab, preserving order within each tier. Closing a tab still selects its neighbour.
|
||||
- A `session.panel` menu item's `primary: true` makes the strip's + run it directly; the first primary item in `order` wins. It does not run automatically when an empty side region opens: the host shows its New tab view with the non-primary items that are not already open. `opened: true` hides an ordinary item from that launcher list, but not from the + menu.
|
||||
|
||||
```ts
|
||||
ctx.add(Panel, {
|
||||
|
||||
@@ -91,9 +91,9 @@ export interface SessionPanelMenuItem extends MenuItemBase {
|
||||
*/
|
||||
readonly opened?: boolean
|
||||
/**
|
||||
* Marks the item that opens a new tab: + runs it directly instead of opening the menu, and the side region runs it
|
||||
* when it opens with no tabs. The first primary item in `order` wins. Without one, + opens the menu and an empty side
|
||||
* region lists the items. Omit it for an ordinary item.
|
||||
* Makes + run this item directly instead of showing the host's launcher choices. The first primary item in `order`
|
||||
* wins. Opening the side region with no tabs still shows the host's New tab view, which lists non-primary items
|
||||
* that are not already open. Omit it for an ordinary launcher item.
|
||||
*/
|
||||
readonly primary?: true
|
||||
/** Runs the item. */
|
||||
|
||||
Reference in New Issue
Block a user