mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
feat(app): allow multiple open file tabs (#57182)
This commit is contained in:
committed by
GitHub
Unverified
parent
c7bd580124
commit
879479b467
@@ -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 })
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user