mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
fix(app): close browser tabs from the address field (#54501)
This commit is contained in:
committed by
GitHub
Unverified
parent
e9def88a77
commit
84e095526c
@@ -1,4 +1,7 @@
|
||||
import { expect, sourceURL, story } from "../../storybook/playwright/story"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import type { Command } from "@opencode/gui-extensions/sdk"
|
||||
import type { Item } from "../src/runtime/extension/host"
|
||||
|
||||
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
|
||||
|
||||
@@ -9,21 +12,49 @@ const modules = {
|
||||
language: source("../src/runtime/i18n/language.tsx"),
|
||||
browser: source("../../gui-extensions/src/browser/index.ts"),
|
||||
browserRenderer: source("../../gui-extensions/src/browser/renderer.tsx"),
|
||||
browserPanel: source("../../gui-extensions/src/browser/panel.tsx"),
|
||||
file: source("../../gui-extensions/src/file/index.ts"),
|
||||
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
|
||||
commands: source("../src/shell/commands/command.tsx"),
|
||||
sdk: source("../../gui-extensions/src/sdk/index.ts"),
|
||||
render: source("../src/runtime/extension/render.tsx"),
|
||||
settings: source("../src/settings/model.tsx"),
|
||||
platform: source("../src/runtime/platform/platform.tsx"),
|
||||
solid: source("./extension-host.fixture.tsx"),
|
||||
}
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
|
||||
const [
|
||||
{ mountBrowserRegion },
|
||||
host,
|
||||
panels,
|
||||
language,
|
||||
browser,
|
||||
file,
|
||||
commands,
|
||||
sdk,
|
||||
renderer,
|
||||
settings,
|
||||
platform,
|
||||
solid,
|
||||
] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.host),
|
||||
import(modules.panels),
|
||||
import(modules.language),
|
||||
import(modules.browser),
|
||||
import(modules.file),
|
||||
import(modules.commands),
|
||||
import(modules.sdk),
|
||||
import(modules.render),
|
||||
import(modules.settings),
|
||||
import(modules.platform),
|
||||
import(modules.solid),
|
||||
// Load the pane before mounting: Vite must not reload the page to optimize its dependencies mid-fixture.
|
||||
import(modules.browserPanel),
|
||||
])
|
||||
|
||||
mountBrowserRegion({
|
||||
@@ -35,10 +66,87 @@ story.beforeEach(async ({ mount, page }) => {
|
||||
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
|
||||
{ ...file.default, renderer: () => import(modules.fileRenderer) },
|
||||
],
|
||||
Shell: (props: ParentProps) =>
|
||||
solid.createComponent(platform.PlatformProvider, {
|
||||
value: { platform: "web", openExternal() {}, restart: async () => undefined, notify: async () => undefined },
|
||||
get children() {
|
||||
return solid.createComponent(settings.SettingsProvider, {
|
||||
get children() {
|
||||
return solid.createComponent(commands.CommandProvider, {
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
Commands: (props: { close: () => void }) => {
|
||||
const command = commands.useCommand()
|
||||
const extensions = host.useExtensionHost()
|
||||
command.register("fixture-session", () => [
|
||||
{
|
||||
id: "tab.close",
|
||||
title: "Close session tab",
|
||||
keybind: "mod+w",
|
||||
onSelect: props.close,
|
||||
},
|
||||
])
|
||||
// Storybook aliases the app's command hook to a no-op. Bridge the real extension contributions
|
||||
// to the real dispatcher imported by source URL, so the shortcut runs rather than its alias.
|
||||
command.register("fixture-extensions", () =>
|
||||
extensions.items(sdk.Command).map((item: Item<Command>) => {
|
||||
const scope = item.value.scope
|
||||
|
||||
return {
|
||||
id: `${item.extension}.${item.value.id}`,
|
||||
title: item.value.title,
|
||||
keybind: item.value.bind,
|
||||
disabled: item.value.enabled === false,
|
||||
hidden: item.value.hidden,
|
||||
editable: item.value.editable,
|
||||
when: scope
|
||||
? (event: KeyboardEvent) => event.target instanceof Element && !!event.target.closest(scope)
|
||||
: undefined,
|
||||
onSelect: () => item.value.run(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return null
|
||||
},
|
||||
Contribution: renderer.Contribution,
|
||||
})
|
||||
}, modules)
|
||||
})
|
||||
|
||||
story("closes a blank browser tab from its address field without closing the session", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const outside = root.getByRole("button", { name: "Beta", exact: true })
|
||||
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
|
||||
await outside.click()
|
||||
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Prove the scope while browser.close is still registered.
|
||||
await outside.focus()
|
||||
await outside.press("ControlOrMeta+w")
|
||||
await expect(root.getByTestId("session-state")).toHaveText("closed")
|
||||
await expect(root.getByTestId("browser-closes")).toHaveText("[]")
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toBeVisible()
|
||||
await outside.click()
|
||||
await expect(root.getByTestId("session-state")).toHaveText("open")
|
||||
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("example.com")
|
||||
await expect(address).toBeFocused()
|
||||
await address.press("ControlOrMeta+w")
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveCount(0)
|
||||
await expect(root.getByTestId("browser-closes")).toHaveText('["tab_33333333-3333-3333-3333-333333333333"]')
|
||||
await expect(root.getByTestId("session-state")).toHaveText("open")
|
||||
})
|
||||
|
||||
story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const tabs = root.getByRole("tab")
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
"command.reload": "Reload browser page",
|
||||
"command.inspect": "Select an element in the browser page",
|
||||
"command.address": "Focus browser address",
|
||||
"command.close": "Close browser tab",
|
||||
"tab.title": "Browser",
|
||||
"tab.new": "New tab",
|
||||
"address.label": "Browser address",
|
||||
|
||||
@@ -57,6 +57,8 @@ type PaneHandle = {
|
||||
visible: () => boolean
|
||||
address: () => string
|
||||
reload: () => void
|
||||
/** Closes the selected browser tab, including a blank page. */
|
||||
close: () => void
|
||||
inspectable: () => boolean
|
||||
/** Turns the element picker on or off. */
|
||||
inspect: () => void
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type MountedSession,
|
||||
type Mutable,
|
||||
type PanelFrame,
|
||||
type Panel,
|
||||
type PanelTab,
|
||||
type Router,
|
||||
type Servers,
|
||||
@@ -585,10 +586,16 @@ type RegionHost = {
|
||||
}): {
|
||||
keys(): readonly string[]
|
||||
active(): string | undefined
|
||||
entry(key: string): { readonly tab: PanelTab } | undefined
|
||||
entry(key: string): { readonly tab: PanelTab; readonly extension: string; readonly provider: Panel } | undefined
|
||||
}
|
||||
/** The real browser and file extensions. */
|
||||
definitions: readonly Definition[]
|
||||
/** Real shell command providers for shortcut cases. */
|
||||
Shell: ParentComponent
|
||||
/** Shell fallback and the real extension command adapter. */
|
||||
Commands: Component<{ close: () => void }>
|
||||
/** The real host wrapper that renders an extension in its context. */
|
||||
Contribution: Component<{ extension: string; children: () => JSX.Element }>
|
||||
}
|
||||
|
||||
type RegionFixtureState = {
|
||||
@@ -598,6 +605,12 @@ type RegionFixtureState = {
|
||||
registrations: { binding: string; session: string; restore: number }[]
|
||||
/** The pane's Ipc is gone, as while its main extension reloads or is disabled. */
|
||||
away: boolean
|
||||
/** Whether the fixture's shell session was closed by its fallback shortcut. */
|
||||
closed: boolean
|
||||
/** The page main reports, blank or loaded. */
|
||||
blank: boolean
|
||||
/** Native browser tab close requests, by tab id. */
|
||||
closes: string[]
|
||||
}
|
||||
|
||||
// Component-test fixture: the real side region over the real browser and file extensions, with the host's
|
||||
@@ -622,6 +635,9 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
},
|
||||
registrations: [],
|
||||
away: false,
|
||||
closed: false,
|
||||
blank: false,
|
||||
closes: [],
|
||||
})
|
||||
|
||||
// The file tree's state, Changes or All files, as the file extension stores it.
|
||||
@@ -700,10 +716,10 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
const view = () => views.get(store.session) ?? fallback
|
||||
|
||||
// One screen object while the strip mounts, whichever session it routes.
|
||||
// SAFETY: this fixture draws tab triggers only. Its file model implements the list, normalize and focus paths;
|
||||
// comment, composer and file-view operations are never invoked here.
|
||||
// SAFETY: this fixture draws file tab triggers and the browser pane. Its file model implements list,
|
||||
// normalize and focus; the browser has a composer attach callback. File-view operations are never invoked here.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
const screen = { file } as unknown as SessionScreen
|
||||
const screen = { file, composer: { attach() {} } } as unknown as SessionScreen
|
||||
|
||||
const layout = (extension: string): Layout => ({
|
||||
narrow: () => false,
|
||||
@@ -781,7 +797,16 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
])
|
||||
},
|
||||
load: async () => undefined,
|
||||
command: async () => undefined,
|
||||
command: async (value) => {
|
||||
if (value.command.type !== "tabs.close") return
|
||||
|
||||
const tabID = value.command.tabID
|
||||
|
||||
setStore("closes", (items) => [...items, tabID])
|
||||
listeners.forEach((listener) =>
|
||||
listener({ binding: value.binding, event: { type: "state", state: { tabs: [], focusedTabID: null } } }),
|
||||
)
|
||||
},
|
||||
inspect: async () => undefined,
|
||||
highlight: async () => undefined,
|
||||
zoom: async () => undefined,
|
||||
@@ -806,7 +831,9 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
keybinds: () => keybinds,
|
||||
servers: () => servers,
|
||||
workspaces: () => workspaces,
|
||||
desktop: () => undefined,
|
||||
desktop: () => ({ zoom: () => 1 }),
|
||||
embeds: () => ({ View: () => null, capture: async () => undefined }),
|
||||
system: () => ({ copy: async () => undefined, openExternal() {}, save: async () => false }),
|
||||
sessions: () => ({ list: () => refs, current: view }),
|
||||
screen: () => ({ current: () => screen }),
|
||||
layout,
|
||||
@@ -823,8 +850,8 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
|
||||
const tab = {
|
||||
id: tabID,
|
||||
url: "http://localhost:4173/",
|
||||
title: "Preview",
|
||||
url: store.blank ? "about:blank" : "http://localhost:4173/",
|
||||
title: store.blank ? "" : "Preview",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
@@ -857,6 +884,12 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
}),
|
||||
})
|
||||
|
||||
const browser = () => {
|
||||
const entry = region.entry(region.active() ?? "")
|
||||
|
||||
return entry?.extension === "browser" ? entry : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The host strip draws a trigger for each of these keys. */}
|
||||
@@ -870,6 +903,35 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
</For>
|
||||
</div>
|
||||
<p data-testid="selected">{region.active() ?? "none"}</p>
|
||||
<Show when={browser()}>
|
||||
{(entry) => (
|
||||
<PanelContext.Provider
|
||||
value={{
|
||||
visible: () => true,
|
||||
present: () => true,
|
||||
placement: () => "side",
|
||||
reserve: () => false,
|
||||
animate: () => false,
|
||||
sidebar: { opened: () => false, width: () => 0, transition: () => false, resize() {}, toggle() {} },
|
||||
open: () => [tabID],
|
||||
}}
|
||||
>
|
||||
<input.Contribution extension={entry().extension}>
|
||||
{() =>
|
||||
entry().provider.render({
|
||||
get tab() {
|
||||
return entry().tab
|
||||
},
|
||||
get session() {
|
||||
return view()
|
||||
},
|
||||
screen,
|
||||
})
|
||||
}
|
||||
</input.Contribution>
|
||||
</PanelContext.Provider>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -882,31 +944,44 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
|
||||
return (
|
||||
<DialogProvider>
|
||||
<input.ExtensionHostProvider
|
||||
definitions={input.definitions}
|
||||
disabled={() => new Set<string>()}
|
||||
apis={apis}
|
||||
whenMounted={(run) => {
|
||||
run()
|
||||
<input.Shell>
|
||||
<input.ExtensionHostProvider
|
||||
definitions={input.definitions}
|
||||
disabled={() => new Set<string>()}
|
||||
apis={apis}
|
||||
whenMounted={(run) => {
|
||||
run()
|
||||
|
||||
return () => undefined
|
||||
}}
|
||||
ipc={(token) => (token.id === BrowserPane.id && !store.away ? pane : undefined)}
|
||||
>
|
||||
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Restored side strip</h1>
|
||||
<nav style={{ display: "flex", gap: "12px", margin: "16px 0" }}>
|
||||
<button onClick={() => setStore("session", beta)}>Beta</button>
|
||||
<button onClick={inventory}>First inventory</button>
|
||||
<button onClick={() => setStore("away", true)}>Pane away</button>
|
||||
<button onClick={() => setStore("away", false)}>Pane back</button>
|
||||
</nav>
|
||||
<p>Registrations: {store.registrations.length}</p>
|
||||
<p>Beta restores: {latest()?.restore ?? 0}</p>
|
||||
<p data-testid="tree">{JSON.stringify(tree())}</p>
|
||||
<Ready>
|
||||
<Strip />
|
||||
</Ready>
|
||||
</input.ExtensionHostProvider>
|
||||
return () => undefined
|
||||
}}
|
||||
ipc={(token) => (token.id === BrowserPane.id && !store.away ? pane : undefined)}
|
||||
>
|
||||
<input.Commands close={() => setStore("closed", true)} />
|
||||
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Restored side strip</h1>
|
||||
<nav style={{ display: "flex", gap: "12px", margin: "16px 0" }}>
|
||||
<button onClick={() => setStore({ session: beta, closed: false })}>Beta</button>
|
||||
<button onClick={inventory}>First inventory</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStore("blank", true)
|
||||
inventory()
|
||||
}}
|
||||
>
|
||||
Blank page
|
||||
</button>
|
||||
<button onClick={() => setStore("away", true)}>Pane away</button>
|
||||
<button onClick={() => setStore("away", false)}>Pane back</button>
|
||||
</nav>
|
||||
<p>Registrations: {store.registrations.length}</p>
|
||||
<p>Beta restores: {latest()?.restore ?? 0}</p>
|
||||
<p data-testid="tree">{JSON.stringify(tree())}</p>
|
||||
<p data-testid="session-state">{store.closed ? "closed" : "open"}</p>
|
||||
<p data-testid="browser-closes">{JSON.stringify(store.closes)}</p>
|
||||
<Ready>
|
||||
<Strip />
|
||||
</Ready>
|
||||
</input.ExtensionHostProvider>
|
||||
</input.Shell>
|
||||
</DialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -384,6 +384,11 @@ export default function SessionBrowserPane(props: {
|
||||
|
||||
if (tab) command({ type: "reload", tabID: tab.id })
|
||||
},
|
||||
close: () => {
|
||||
const tab = state()
|
||||
|
||||
if (tab) props.model.closeTab(props.session, tab.id)
|
||||
},
|
||||
inspectable,
|
||||
inspect: toggleInspect,
|
||||
focusAddress,
|
||||
|
||||
@@ -88,6 +88,25 @@ const setup: Setup<typeof definition> = (ctx) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Blank pages have no focused native view, so the app must claim close inside the pane too.
|
||||
ctx.add(Command, (): Command | undefined => {
|
||||
const pane = model()?.pane()
|
||||
|
||||
if (!pane) return undefined
|
||||
|
||||
return {
|
||||
id: "close",
|
||||
title: ctx.t("command.close"),
|
||||
group: ctx.t("command.category.view"),
|
||||
bind: "mod+w",
|
||||
scope: "#browser-panel",
|
||||
hidden: true,
|
||||
editable: true,
|
||||
enabled: pane.visible(),
|
||||
run: pane.close,
|
||||
}
|
||||
})
|
||||
|
||||
// Ctrl+Shift+C copies in the terminal, so only the focused page claims it, as in Chromium.
|
||||
ctx.add(Command, (): Command | undefined => {
|
||||
const pane = model()?.pane()
|
||||
|
||||
Reference in New Issue
Block a user