Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/app/e2e/regression/side-panels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ test("new tab selection and closing stay in the focused pane", async ({ page })
await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true")
})

test("closing the last New tab closes the side region, and only a New tab lists the launchers", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await openSession(page, { name: "LastNewTab", seed: { panes: { ses_lastnewtab: { review: true } } } })

const panel = page.locator("#review-panel")
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const newTab = panel.getByRole("tab", { name: "New tab", exact: true })
const review = panel.getByRole("button", { name: /^Review/ })

// An empty side region opens on a New tab listing the launchers.
await expect(newTab).toHaveAttribute("aria-selected", "true")
await expect(review).toBeVisible()

// Closing it leaves no tab, so the region closes rather than listing the launchers without one.
await newTab.locator("..").getByRole("button", { name: "Close tab" }).click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")

await toggle.click()
await expect(newTab).toHaveAttribute("aria-selected", "true")
await expect(review).toBeVisible()
})

// 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"))
Expand Down
16 changes: 11 additions & 5 deletions packages/app/src/runtime/plugin/panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function createRegion(input: {
open: (key: string) => void
/** Whether the side region is open, and the user's toggle for it. */
side: Accessor<{ opened(): boolean; toggle(): void }>
/** Runs when a close leaves no stored tab, in the same batch as the close. */
/** Runs when a close leaves no stored tab and no New tab, in the same batch as the close. */
onEmptied?: () => void
/** Runs work that forces a layout, like moving focus, once the region's open motion lands. */
settled?: (run: () => void) => void
Expand Down Expand Up @@ -327,6 +327,14 @@ export function createRegion(input: {
if (state.adding.some((value) => !live(value))) setState("adding", (list) => list.filter(live))
})

// Closing the last New tab of a strip with no tabs closes the region, as closing its last tab does.
const dismissNewTab = (id: string) =>
batch(() => {
setState("adding", (list) => list.filter((value) => value.id !== id))

if (stored().length === 0 && !state.adding.some(live)) input.onEmptied?.()
})

return {
/** Whether a key event belongs to the side region rather than the session around it. */
focus: createSideFocus(),
Expand Down Expand Up @@ -372,15 +380,13 @@ export function createRegion(input: {
setState("chosen", pane, id)
})
},
dismissNewTab(id: string) {
setState("adding", (list) => list.filter((value) => value.id !== id))
},
dismissNewTab,
/** 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
setState("adding", (list) => list.filter((entry) => entry.id !== value.id))
dismissNewTab(value.id)

return true
},
Expand Down
26 changes: 9 additions & 17 deletions packages/app/src/runtime/plugin/side-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,23 +383,15 @@ 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">
{/* After + runs a primary item, its tab owns the empty pane until it arrives. */}
{/* After + runs a primary item, its tab owns the empty pane until it arrives. The launchers list only in a
New tab, not in a pane with no tabs. */}
<Show when={!primary()}>
<Show
when={launchers().length > 0}
fallback={
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
}
>
<Launchers items={launchers()} onChoose={target} closed={closed} />
</Show>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.files.selectToOpen")}</div>
</div>
</div>
</Show>
</Tabs.Content>
</Show>
Expand Down Expand Up @@ -518,7 +510,7 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () =
)
}

/** The + menu items as rows, then the session's recently closed tabs, in the empty side region and the new tab view. */
/** The + menu items as rows, then the session's recently closed tabs, in the new tab view. */
function Launchers(props: {
items: readonly SessionPanelMenuItem[]
onChoose: () => void
Expand Down
Loading