feat(app): allow multiple open file tabs (#57182)

This commit is contained in:
usrnk1
2026-10-19 21:48:29 +02:00
committed by GitHub
Unverified
parent c7bd580124
commit 879479b467
8 changed files with 75 additions and 27 deletions
@@ -270,7 +270,10 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
const launcher = panel.getByRole("tab", { name: "Open file", exact: true })
await browseFiles(page)
await expect(launcher).toHaveAttribute("aria-selected", "true")
// The launcher opens beside the preview; closing the file leaves only the launcher stored.
await readme.click({ button: "middle" })
await expect(readme).toHaveCount(0)
await expect(launcher).toHaveAttribute("aria-selected", "true")
await page.reload()
await expect(launcher).toHaveAttribute("aria-selected", "true")
await page.setViewportSize({ width: 390, height: 844 })
+15 -2
View File
@@ -171,7 +171,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
await expect(tab("Context")).toHaveAttribute("aria-selected", "true")
await expect(tab("Open file")).toHaveAttribute("aria-selected", "false")
await expect(sidebar).toBeHidden()
// The New tab view hides launchers whose tab is open, so the strip's tab returns to it.
// The strip's launcher tab returns to it.
await tab("Open file").click()
await expect(tab("Open file")).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("ReviewOpenFile", { exact: true })).toBeVisible()
@@ -204,8 +204,9 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
await expect(tab("README.md")).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
// A new launcher opens beside the preview rather than replacing it.
await browseFiles(page)
await expect(tab("README.md")).toHaveCount(0)
await expect(tab("README.md")).toHaveAttribute("aria-selected", "false")
await expect(sidebar).toBeVisible()
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
@@ -228,6 +229,18 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
await expect(tab("Open file")).toHaveCount(0)
await expect(tab("nested.ts")).toHaveAttribute("aria-selected", "true")
// The New tab view keeps offering Open file, so several launchers stay open; a pick replaces the selected one.
await openFileLauncher(panel)
await openFileLauncher(panel)
await expect(tab("Open file")).toHaveCount(2)
await panel.getByRole("button", { name: "README.md" }).click()
await expect(tab("README.md")).toHaveAttribute("aria-selected", "true")
await expect(tab("Open file")).toHaveCount(1)
await tab("Open file").click()
await page.keyboard.press("Control+w")
await expect(tab("Open file")).toHaveCount(0)
await expect(tab("nested.ts")).toHaveAttribute("aria-selected", "true")
// The file browser beside file tabs keeps its DOM and scroll position across preview and pinned tab switches.
await sidebarToggle.click()
await filter.fill("")
+1 -1
View File
@@ -7,7 +7,7 @@ export async function openFileLauncher(panel: Locator) {
await panel.getByRole("button", { name: /^Open file/ }).click()
}
/** Opens the file browser with its shortcut, which still works while a file tab hides "Open file" from the New tab view. */
/** Opens the file browser with its shortcut: it reuses the selected launcher, else opens another. */
export async function browseFiles(page: Page) {
await page.keyboard.press("Control+Alt+P")
}
@@ -494,7 +494,7 @@ function LauncherRow(props: { item: SessionPanelMenuItem }): JSX.Element {
return (
<button
type="button"
class="flex h-[41px] w-full items-center justify-between gap-4 rounded-[8px] border-[0.5px] border-v2-border-border-base bg-v2-background-bg-base px-3.5 text-start hover:bg-v2-overlay-simple-overlay-hover"
class="flex h-[41px] w-full items-center justify-between gap-4 rounded-[8px] border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01 px-3.5 text-start hover:bg-v2-background-bg-layer-02"
// The new tab view stays until the tab the item opens selects itself, so nothing shows in between.
onClick={() => props.item.run()}
>
@@ -3,6 +3,8 @@ import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabStat
const OPEN = "file:open"
const SECOND = "file:open:2"
const launchers = new Set([OPEN])
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
@@ -31,6 +33,16 @@ test.each([
run: () => previewSessionTab(state(["file://a.ts", OPEN], OPEN), "file://b.ts", launchers),
expected: state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"),
},
{
name: "preview replaces the selected Open File placeholder among several",
run: () =>
previewSessionTab(
state([OPEN, "file://a.ts", SECOND], SECOND, "file://a.ts"),
"file://b.ts",
new Set([OPEN, SECOND]),
),
expected: state([OPEN, "file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"),
},
{
name: "open pins the current preview",
run: () => openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts"),
+11 -3
View File
@@ -8,9 +8,17 @@ export type SessionTabState = {
preview?: string
}
/** A launcher tab (e.g. the file browser) stays replaceable by the next preview even after reload. */
const sessionTabPreview = (current: SessionTabState, launchers: ReadonlySet<string>) =>
current.preview ?? current.tabs.all.find((tab) => launchers.has(tab))
/**
* A launcher tab (e.g. the file browser) stays replaceable by the next preview even after reload. With several
* launchers open, the selected one is the one its pick replaces.
*/
const sessionTabPreview = (current: SessionTabState, launchers: ReadonlySet<string>) => {
const active = current.tabs.active
if (active && launchers.has(active)) return active
return current.preview ?? current.tabs.all.find((tab) => launchers.has(tab))
}
export function previewSessionTab(
current: SessionTabState,
+29 -17
View File
@@ -45,6 +45,8 @@ import tabStyles from "./tabs.css?inline"
const OPEN = "open"
const isLauncher = (id: string) => id === OPEN || id.startsWith(`${OPEN}:`)
const GROUP = "browser"
const TABPANEL = "session-side-panel-file-browser-tabpanel"
@@ -145,10 +147,19 @@ const setup: Setup<typeof File> = (ctx) => {
shared.reveal.run(session.key, path)
}
const openPicker = (session: MountedSession, query: string, options?: OpenOptions) => {
// Reuses the selected launcher; otherwise wide screens add another one beside the open tabs, so several can stay
// open. Narrow screens never store a launcher, so they keep the one.
const openPicker = (session: MountedSession, query: string, options?: OpenOptions & { another?: boolean }) => {
const selected = options?.another
? undefined
: layout.stored(session).find((id) => isLauncher(id) && active(session, id))
batch(() => {
updateFilter(session.key, { text: query, browsing: true })
layout.open(`file:${OPEN}`, session, { tab: "preview", background: options?.background })
if (selected || layout.narrow())
layout.open(`file:${selected ?? OPEN}`, session, { tab: "preview", background: options?.background })
else layout.open(`file:${OPEN}:${crypto.randomUUID()}`, session, { tab: "select" })
if (!options?.background && layout.narrow() && !layout.side.opened(session)) layout.side.toggle(session)
})
@@ -257,8 +268,8 @@ const setup: Setup<typeof File> = (ctx) => {
}),
)
const launcher: PanelTab = {
id: OPEN,
const launcher = (id: string): PanelTab => ({
id,
get title() {
return ctx.t("command.open")
},
@@ -273,7 +284,7 @@ const setup: Setup<typeof File> = (ctx) => {
transient: true,
group: GROUP,
dom: { panel: TABPANEL },
}
})
const fileTab = (files: Files, id: string): PanelTab => {
const path = () => fileTabPath(files, id)
@@ -320,16 +331,14 @@ const setup: Setup<typeof File> = (ctx) => {
},
list(input) {
return input.open.flatMap((id) => {
if (id === OPEN) return [launcher]
if (!isFileTab(id)) return []
if (!isLauncher(id) && !isFileTab(id)) return []
const cache = tabsOf(input.screen)
const existing = cache.get(id)
if (existing) return [existing]
const created = fileTab(input.screen.file, id)
const created = isLauncher(id) ? launcher(id) : fileTab(input.screen.file, id)
cache.set(id, created)
@@ -387,20 +396,23 @@ const setup: Setup<typeof File> = (ctx) => {
}),
)
ctx.add(MenuItem, (): MenuItem => {
const session = sessions.current()
return {
// A new tab always offers another launcher, however many are open.
ctx.add(
MenuItem,
(): MenuItem => ({
menu: "session.panel",
id: "open",
title: ctx.t("command.open"),
opened: !!session && layout.stored(session).some((id) => id === OPEN || isFileTab(id)),
icon: "file-tree",
keybind: "file.browse",
order: 30,
run: browse,
}
})
run: () => {
const session = sessions.current()
if (session) openPicker(session, "", { another: true })
},
}),
)
const OpenInAppButton = lazy(() => import("./open-in-app"))
+3 -3
View File
@@ -1,5 +1,5 @@
/* File tabs and the Open file launcher: tighter close button, as the host strip styled them before extensions. */
:root body #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-list"] [data-slot="tabs-trigger-wrapper"]:is([data-value="file:open"], [data-value^="file://"])
:root body #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-list"] [data-slot="tabs-trigger-wrapper"]:is([data-value="file:open"], [data-value^="file:open:"], [data-value^="file://"])
{
gap: 8px;
padding-inline-end: 4px;
@@ -11,7 +11,7 @@
padding-inline-start: 8px;
}
:root body #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-list"] [data-slot="tabs-trigger-wrapper"]:is([data-value="file:open"], [data-value^="file://"]) [data-slot="tabs-trigger-close-button"]
:root body #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-list"] [data-slot="tabs-trigger-wrapper"]:is([data-value="file:open"], [data-value^="file:open:"], [data-value^="file://"]) [data-slot="tabs-trigger-close-button"]
{
margin-inline-start: 0;
}
@@ -22,6 +22,6 @@
#review-panel
[data-component="tabs"][data-variant="normal"][data-orientation="horizontal"]
[data-slot="tabs-list"]
[data-slot="tabs-trigger"][data-value="file:open"][data-selected] {
[data-slot="tabs-trigger"]:is([data-value="file:open"], [data-value^="file:open:"])[data-selected] {
color: var(--v2-text-text-muted);
}