mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 09:56:24 +00:00
Compare commits
3
Commits
v2
...
browser-extension
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1404be1cbd | ||
|
|
39310ee609 | ||
|
|
cc40f5844c |
@@ -63,7 +63,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
@@ -426,7 +426,9 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/plugin-browser-desktop": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
@@ -597,6 +599,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
@@ -628,6 +631,28 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin-browser-desktop": {
|
||||
"name": "@opencode/plugin-browser-desktop",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"lighthouse": "13.4.1",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"electron": "42.10.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
"name": "@opencode/posts",
|
||||
"dependencies": {
|
||||
@@ -2215,6 +2240,8 @@
|
||||
|
||||
"@opencode/plugin-browser": ["@opencode/plugin-browser@workspace:packages/plugin-browser"],
|
||||
|
||||
"@opencode/plugin-browser-desktop": ["@opencode/plugin-browser-desktop@workspace:packages/plugin-browser-desktop"],
|
||||
|
||||
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
|
||||
|
||||
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { AppBaseProviders, AppInterface } from "../../src/app"
|
||||
import { PlatformProvider } from "../../src/runtime/platform/platform"
|
||||
import { createWebPlatform } from "../../src/runtime/platform/web"
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
import { Plugin } from "@opencode/plugin/desktop"
|
||||
import { Panel } from "@opencode/plugin/desktop/solid"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { Stack, Text } from "@opencode/ui/layout"
|
||||
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
|
||||
const plugin = Plugin.define({
|
||||
id: "example.inspector",
|
||||
setup(ctx) {
|
||||
const [state, setState] = createStore({ available: true, closed: 0 })
|
||||
const [draft, saveDraft] = ctx.storage.memory("draft", { initial: { text: "" } })
|
||||
ctx.ui.slot({
|
||||
append: "titlebar.actions",
|
||||
render: () => (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const session = ctx.sessions.current()
|
||||
if (session) ctx.ui.panel.open("notes", session)
|
||||
}}
|
||||
>
|
||||
Open notes
|
||||
</Button>
|
||||
<Button onClick={() => setState("available", !state.available)}>Toggle contribution</Button>
|
||||
</>
|
||||
),
|
||||
})
|
||||
ctx.ui.slot({
|
||||
append: "session.panel",
|
||||
when: () => state.available,
|
||||
render: () => (
|
||||
<>
|
||||
<Panel id="notes" title="Notes" onClose={() => setState("closed", (count) => count + 1)}>
|
||||
<Stack padding="medium">
|
||||
<TextInput
|
||||
aria-label="Notes draft"
|
||||
value={draft.text}
|
||||
onInput={(event) =>
|
||||
saveDraft((draft) => {
|
||||
draft.text = event.currentTarget.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Panel>
|
||||
<Panel id="results" title="Results">
|
||||
<Stack padding="medium">
|
||||
<Text>Closed notes: {state.closed}</Text>
|
||||
</Stack>
|
||||
</Panel>
|
||||
</>
|
||||
),
|
||||
})
|
||||
},
|
||||
})
|
||||
const web = createWebPlatform("test")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: `/server/${base64Encode(web.currentServerUrl!)}/session/${fixture.sourceID}` })
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={{ ...web.platform, extensionPlugins: [plugin] }}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface
|
||||
servers={[{ type: "http", http: { url: web.currentServerUrl! } }]}
|
||||
defaultServer={ServerConnection.Key.make(web.currentServerUrl!)}
|
||||
router={(props) => <MemoryRouter {...props} history={history} />}
|
||||
/>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
document.getElementById("root")!,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("independent panel instances retain drafts, close explicitly, and obey plugin availability", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto("/e2e/extensions/fixture.html")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Open notes", exact: true }).click()
|
||||
await page.getByRole("textbox", { name: "Notes draft", exact: true }).fill("Retained draft")
|
||||
await page.getByRole("tab", { name: "Results", exact: true }).click()
|
||||
await expect(page.getByText("Closed notes: 0", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "Notes", exact: true }).click()
|
||||
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
|
||||
await page
|
||||
.locator('[data-slot="tabs-trigger-wrapper"][data-value="extension:example.inspector:notes"]')
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await page.getByRole("tab", { name: "Results", exact: true }).click()
|
||||
await expect(page.getByText("Closed notes: 1", { exact: true })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Open notes", exact: true }).click()
|
||||
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
|
||||
await page.screenshot({ path: info.outputPath("extension-panels.png") })
|
||||
await page
|
||||
.getByRole("tab", { name: "Results", exact: true })
|
||||
.dragTo(page.getByRole("tab", { name: "Notes", exact: true }))
|
||||
await expect(page.getByRole("tab", { name: /^(Notes|Results)$/ })).toHaveText(["Results", "Notes"])
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await page.locator('header a[href$="/ses_smoke_source"]').click()
|
||||
await page.getByRole("button", { name: "Open notes", exact: true }).click()
|
||||
await expect(page.getByRole("tab", { name: /^(Notes|Results)$/ })).toHaveText(["Results", "Notes"])
|
||||
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
|
||||
await page.getByRole("button", { name: "Toggle contribution", exact: true }).click()
|
||||
await expect(page.getByRole("tab", { name: "Notes", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle contribution", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Open notes", exact: true }).click()
|
||||
await expect(page.getByRole("textbox", { name: "Notes draft", exact: true })).toHaveValue("Retained draft")
|
||||
})
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
|
||||
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export type {
|
||||
BrowserPaneCommand,
|
||||
BrowserPaneEndpoint,
|
||||
BrowserPaneEvent,
|
||||
BrowserPaneLayout,
|
||||
BrowserPanePlatform,
|
||||
BrowserPaneRegistration,
|
||||
BrowserPaneState,
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const extensionTabKey = (plugin: string, id: string) => `extension:${plugin}:${id}`
|
||||
export const isExtensionTab = (id: string | undefined) => !!id?.startsWith("extension:")
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useDesktopExtensions } from "./provider"
|
||||
|
||||
/** Native geometry, clipping and portal occlusion are host behavior shared by all extensions. */
|
||||
export function ExtensionNativeSurface(props: { extensionID: string; id: string }) {
|
||||
const host = useDesktopExtensions()
|
||||
const dialog = useDialog()
|
||||
let surface: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let until = 0
|
||||
let last = ""
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const covered = Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some(
|
||||
(element) => {
|
||||
const other = element.getBoundingClientRect()
|
||||
return (
|
||||
other.width > 0 &&
|
||||
other.left < rect.right &&
|
||||
other.right > rect.left &&
|
||||
other.top < rect.bottom &&
|
||||
other.bottom > rect.top
|
||||
)
|
||||
},
|
||||
)
|
||||
const zoom = host.zoom()
|
||||
const visible =
|
||||
document.visibilityState === "visible" &&
|
||||
!dialog.active &&
|
||||
!covered &&
|
||||
surface.checkVisibility({ checkVisibilityCSS: true })
|
||||
const color = getComputedStyle(
|
||||
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
|
||||
).backgroundColor
|
||||
const key = `${props.id}:${visible}:${rect.x}:${rect.y}:${rect.width}:${rect.height}:${zoom}:${color}:${devicePixelRatio}`
|
||||
if (key === last) return
|
||||
last = key
|
||||
if (paint) {
|
||||
paint.clearRect(0, 0, 1, 1)
|
||||
paint.fillStyle = color
|
||||
paint.fillRect(0, 0, 1, 1)
|
||||
}
|
||||
const rgba = paint?.getImageData(0, 0, 1, 1).data
|
||||
host.transport?.surface(props.extensionID, props.id, {
|
||||
visible,
|
||||
bounds: {
|
||||
x: Math.round(rect.left * zoom),
|
||||
y: Math.round(rect.top * zoom),
|
||||
width: Math.max(0, Math.round(rect.right * zoom) - Math.round(rect.left * zoom)),
|
||||
height: Math.max(0, Math.round(rect.bottom * zoom) - Math.round(rect.top * zoom)),
|
||||
},
|
||||
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
|
||||
radius: Math.round(10 * zoom),
|
||||
})
|
||||
}
|
||||
const tick = () => {
|
||||
frame = undefined
|
||||
measure()
|
||||
if (performance.now() < until) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
const schedule = () => {
|
||||
until = performance.now() + 300
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
createEffect(() => {
|
||||
props.id
|
||||
dialog.active
|
||||
host.zoom()
|
||||
schedule()
|
||||
})
|
||||
createResizeObserver(() => surface, measure)
|
||||
createEventListener(window, "resize", schedule)
|
||||
createEventListener(document, "visibilitychange", schedule)
|
||||
const portals = new MutationObserver(schedule)
|
||||
portals.observe(document.body, { childList: true })
|
||||
const theme = new MutationObserver(schedule)
|
||||
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
|
||||
onCleanup(() => {
|
||||
portals.disconnect()
|
||||
theme.disconnect()
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
host.transport?.surface(props.extensionID, props.id)
|
||||
})
|
||||
return <div ref={surface} data-component="native-surface" class="min-h-0 min-w-0 flex-1 bg-v2-background-bg-base" />
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRoot,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
useContext,
|
||||
type ParentProps,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, produce, type Store, type SetStoreFunction } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import type { Context, PanelProps, SessionContext, SlotClaim } from "@opencode/plugin/desktop"
|
||||
import { createLifecycle } from "@opencode/plugin/desktop/lifecycle"
|
||||
import { client } from "@opencode/plugin/desktop/rpc"
|
||||
import { resolveSlots, type Claim, type PlacementKind } from "@opencode/plugin/slots"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useTabs, tabKey } from "@/shell/tabs/tabs"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import { persisted, Persist } from "@/runtime/persistence/storage"
|
||||
import { extensionTabKey } from "./keys"
|
||||
|
||||
export type Contribution = Claim<{ context: Context; when?: () => boolean; render: SlotClaim["render"] }>
|
||||
export type RegisteredPanel = {
|
||||
key: string
|
||||
plugin: string
|
||||
session: SessionContext
|
||||
props: PanelProps
|
||||
render: () => JSX.Element
|
||||
}
|
||||
type PanelHost = { open(id: string): void; close(id: string): void; active(): string | undefined }
|
||||
const HostContext = createContext<ReturnType<typeof createHost>>()
|
||||
|
||||
export function DesktopExtensionsProvider(props: ParentProps) {
|
||||
const host = createHost()
|
||||
return <HostContext.Provider value={host}>{props.children}</HostContext.Provider>
|
||||
}
|
||||
|
||||
export function useDesktopExtensions() {
|
||||
const host = useContext(HostContext)
|
||||
if (!host) throw new Error("Desktop extension host is unavailable")
|
||||
return host
|
||||
}
|
||||
|
||||
export const useOptionalDesktopExtensions = () => useContext(HostContext)
|
||||
|
||||
function createHost() {
|
||||
const platform = usePlatform()
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const route = useCurrentRoute()
|
||||
const commands = useCommand()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
const [state, setState] = createStore({
|
||||
claims: [] as Contribution[],
|
||||
panels: [] as RegisteredPanel[],
|
||||
sessions: [] as SessionContext[],
|
||||
})
|
||||
const sessions = new Map<string, SessionContext>()
|
||||
const hosts = new Map<string, PanelHost>()
|
||||
const instances = new Map<string, { definition: object; dispose: () => void }>()
|
||||
const storage = new Map<string, unknown>()
|
||||
const memories = new Map<string, unknown>()
|
||||
const resolved = createMemo(() =>
|
||||
resolveSlots({
|
||||
paths: new Set([
|
||||
"app",
|
||||
"titlebar.actions",
|
||||
"settings.experimental",
|
||||
"session.panel",
|
||||
"session.panel.actions",
|
||||
"session.composer.top",
|
||||
]),
|
||||
claims: state.claims.filter((claim) => claim.render.when?.() ?? true),
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const all = global.servers.list()
|
||||
platform.extensions?.configure(
|
||||
all.map((connection) => ({
|
||||
id: ServerConnection.key(connection),
|
||||
...connection.http,
|
||||
url: global.ensureServerCtx(connection).sdk.url,
|
||||
})),
|
||||
)
|
||||
const owned = new Set(tabs.store.filter((tab) => tab.type === "session").map(tabKey))
|
||||
Array.from(sessions).forEach(([key, session]) => {
|
||||
if (!owned.has(session.ownerID)) sessions.delete(key)
|
||||
})
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "session") return
|
||||
const connection = all.find((connection) => ServerConnection.key(connection) === tab.server)
|
||||
if (!connection) return
|
||||
const data = global.ensureServerCtx(connection)
|
||||
const server = {
|
||||
id: tab.server,
|
||||
get client() {
|
||||
return data.sdk.api
|
||||
},
|
||||
data: data.data,
|
||||
get compatible() {
|
||||
return !global.servers.health[tab.server]?.incompatible
|
||||
},
|
||||
}
|
||||
Array.from(new Set([tab.sessionId, tab.routeSessionId ?? tab.sessionId])).forEach((id) => {
|
||||
const key = `${tab.server}\n${id}`
|
||||
if (sessions.has(key)) return
|
||||
sessions.set(key, {
|
||||
key,
|
||||
ownerID: tabKey(tab),
|
||||
sessionID: id,
|
||||
server,
|
||||
get creating() {
|
||||
return data.data.session.creating(id)
|
||||
},
|
||||
get location() {
|
||||
return data.data.session.get(id)?.location
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
setState("sessions", Array.from(sessions.values()))
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const value = route()
|
||||
if (value.type !== "session") return
|
||||
return state.sessions.find((session) => session.server.id === value.server && session.sessionID === value.sessionId)
|
||||
})
|
||||
|
||||
const stateFor = <Value extends object>(id: string, key: string, initial: Value, durable: boolean) => {
|
||||
const cache = durable ? storage : memories
|
||||
const name = `${id}.${key}`
|
||||
const previous = cache.get(name)
|
||||
if (previous) return previous as readonly [Store<Value>, (update: (draft: Value) => void) => void]
|
||||
// Storage decodes JSON objects at the boundary; each plugin owns its value's shape and migrations.
|
||||
const pair = runWithOwner(owner, () =>
|
||||
durable
|
||||
? persisted(
|
||||
Persist.global(`extension.${name}`),
|
||||
Schema.Record(Schema.String, Schema.Json),
|
||||
Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))(initial),
|
||||
)
|
||||
: createStore(initial),
|
||||
) as unknown as readonly [Store<Value>, SetStoreFunction<Value>]
|
||||
const value = [pair[0], (update: (draft: Value) => void) => pair[1](produce(update))] as const
|
||||
cache.set(name, value)
|
||||
return value
|
||||
}
|
||||
|
||||
const activate = (definition: NonNullable<typeof platform.extensionPlugins>[number]) =>
|
||||
createRoot((dispose) => {
|
||||
const lifecycle = createLifecycle()
|
||||
const id = definition.id
|
||||
let nextClaim = 0
|
||||
const context: Context = {
|
||||
app: { version: platform.version, windowID: platform.windowID, native: !!platform.extensions },
|
||||
lifecycle,
|
||||
sessions: { list: () => state.sessions, current },
|
||||
storage: {
|
||||
store: (key, options) => stateFor(id, key, options.initial, true),
|
||||
memory: (key, options) => stateFor(id, key, options.initial, false),
|
||||
},
|
||||
i18n: {
|
||||
locale: language.locale,
|
||||
t: (key, params) =>
|
||||
Object.hasOwn(en, key) ? language.t(key as Parameters<typeof language.t>[0], params) : key,
|
||||
},
|
||||
commands: {
|
||||
register(values) {
|
||||
return lifecycle.own(
|
||||
createRoot((dispose) => {
|
||||
commands.register(`${id}/${nextClaim++}`, () =>
|
||||
values().map((command) => ({
|
||||
id: `${id}.${command.id}`,
|
||||
title: command.title,
|
||||
description: command.description,
|
||||
category: command.group,
|
||||
disabled: command.enabled === false,
|
||||
hidden: command.palette === false,
|
||||
keybind: command.bind,
|
||||
slash: command.slash,
|
||||
onSelect: () => {
|
||||
void command.run()
|
||||
},
|
||||
})),
|
||||
)
|
||||
return dispose
|
||||
}),
|
||||
)
|
||||
},
|
||||
dispatch: (commandID) => commands.trigger(`${id}.${commandID}`),
|
||||
},
|
||||
main: {
|
||||
rpc(definition) {
|
||||
const transport = platform.extensions
|
||||
if (!transport) throw new Error("Native desktop extensions are unavailable on this platform")
|
||||
return client(id, definition, transport, lifecycle.signal, lifecycle.own)
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
slot(claim) {
|
||||
const placements = ["append", "prepend", "before", "after", "replace"] as const
|
||||
const kinds = placements.filter((kind) => claim[kind] !== undefined)
|
||||
if (kinds.length !== 1) throw new Error("A slot requires exactly one placement")
|
||||
const kind: PlacementKind = kinds[0]
|
||||
const value: Contribution = {
|
||||
key: `${id}/${nextClaim++}`,
|
||||
plugin: id,
|
||||
placement: { kind, target: claim[kind]! },
|
||||
render: { context, when: claim.when, render: claim.render },
|
||||
}
|
||||
setState("claims", (items) => [...items, value])
|
||||
return lifecycle.own(() => setState("claims", (items) => items.filter((item) => item.key !== value.key)))
|
||||
},
|
||||
panel: {
|
||||
open(localID, session) {
|
||||
const key = extensionTabKey(id, localID)
|
||||
const host = hosts.get(session.key)
|
||||
if (!host || !state.panels.some((panel) => panel.session.key === session.key && panel.key === key))
|
||||
return false
|
||||
host.open(key)
|
||||
return true
|
||||
},
|
||||
close(localID, session) {
|
||||
const key = extensionTabKey(id, localID)
|
||||
const host = hosts.get(session.key)
|
||||
if (!host) return false
|
||||
host.close(key)
|
||||
return true
|
||||
},
|
||||
selected: (localID, session) => hosts.get(session.key)?.active() === extensionTabKey(id, localID),
|
||||
},
|
||||
},
|
||||
}
|
||||
onCleanup(() => {
|
||||
try {
|
||||
lifecycle.dispose()
|
||||
} finally {
|
||||
platform.extensions?.release(id)
|
||||
setState("panels", (items) => items.filter((panel) => panel.plugin !== id))
|
||||
}
|
||||
})
|
||||
const cleanup = definition.setup(context)
|
||||
if (cleanup) lifecycle.own(cleanup)
|
||||
return dispose
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const definitions = platform.extensionPlugins ?? []
|
||||
Array.from(instances).forEach(([id, instance]) => {
|
||||
if (definitions.find((definition) => definition.id === id) === instance.definition) return
|
||||
instance.dispose()
|
||||
instances.delete(id)
|
||||
})
|
||||
definitions.forEach((definition) => {
|
||||
if (instances.has(definition.id)) return
|
||||
instances.set(definition.id, { definition, dispose: activate(definition) })
|
||||
})
|
||||
})
|
||||
onCleanup(() => instances.forEach((instance) => instance.dispose()))
|
||||
return {
|
||||
state,
|
||||
resolved,
|
||||
current,
|
||||
transport: platform.extensions,
|
||||
zoom: () => platform.webviewZoom?.() ?? 1,
|
||||
bind(session: SessionContext, host: PanelHost) {
|
||||
hosts.set(session.key, host)
|
||||
return () => {
|
||||
if (hosts.get(session.key) === host) hosts.delete(session.key)
|
||||
}
|
||||
},
|
||||
register(panel: RegisteredPanel) {
|
||||
if (state.panels.some((item) => item.key === panel.key && item.session.key === panel.session.key))
|
||||
throw new Error(`Duplicate extension panel: ${panel.key}`)
|
||||
setState("panels", (items) => [...items, panel])
|
||||
return () =>
|
||||
setState("panels", (items) =>
|
||||
items.filter((item) => item.key !== panel.key || item.session.key !== panel.session.key),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||
import { useOptionalDesktopExtensions } from "./provider"
|
||||
import { isExtensionTab } from "./keys"
|
||||
import { ExtensionSlot } from "./slot"
|
||||
|
||||
export function useExtensionPanels(input: {
|
||||
serverID: () => string
|
||||
sessionID: () => string | undefined
|
||||
tabs: () => {
|
||||
all(): string[]
|
||||
setAll(value: string[]): void
|
||||
active(): string | undefined
|
||||
setActive(value: string): void
|
||||
close(id: string): void
|
||||
}
|
||||
open(): void
|
||||
}) {
|
||||
const host = useOptionalDesktopExtensions()
|
||||
const session = createMemo(() =>
|
||||
host?.state.sessions.find(
|
||||
(session) => session.sessionID === input.sessionID() && session.server.id === input.serverID(),
|
||||
),
|
||||
)
|
||||
const panels = createMemo(() => host?.state.panels.filter((panel) => panel.session.key === session()?.key) ?? [])
|
||||
createEffect(() => {
|
||||
const current = session()
|
||||
if (!current || !host) return
|
||||
onCleanup(
|
||||
host.bind(current, {
|
||||
open(id) {
|
||||
input.open()
|
||||
const tabs = input.tabs()
|
||||
if (!tabs.all().includes(id)) tabs.setAll([...tabs.all(), id])
|
||||
tabs.setActive(id)
|
||||
},
|
||||
close: (id) => input.tabs().close(id),
|
||||
active: () => input.tabs().active(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => panels().map((panel) => panel.key),
|
||||
(keys, previous) => {
|
||||
const old = new Set(previous ?? [])
|
||||
const tabs = input.tabs()
|
||||
// Persisted instances can be waiting for their declarations to mount. Only
|
||||
// remove contributions observed disappearing during this route lifetime.
|
||||
const removed = new Set(previous?.filter((key) => !keys.includes(key)))
|
||||
const current = tabs.all().filter((key) => !removed.has(key))
|
||||
const added = keys.filter((key) => !old.has(key) && !current.includes(key))
|
||||
if (added.length || current.length !== tabs.all().length) tabs.setAll([...current, ...added])
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => input.tabs().all(),
|
||||
(current, previous) => {
|
||||
previous
|
||||
?.filter((key) => isExtensionTab(key) && !current.includes(key))
|
||||
.forEach((key) =>
|
||||
panels()
|
||||
.find((panel) => panel.key === key)
|
||||
?.props.onClose?.(),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => input.tabs().active(),
|
||||
(active) =>
|
||||
panels()
|
||||
.find((panel) => panel.key === active)
|
||||
?.props.onSelect?.(),
|
||||
),
|
||||
)
|
||||
return {
|
||||
panels,
|
||||
keys: () => panels().map((panel) => panel.key),
|
||||
hasActions: () => {
|
||||
const value = host?.resolved().slotted.get("session.panel.actions")
|
||||
return (
|
||||
!!value &&
|
||||
(!!value.replace || value.before.length + value.prepend.length + value.append.length + value.after.length > 0)
|
||||
)
|
||||
},
|
||||
declarations: () => (
|
||||
<Show when={session()} keyed>
|
||||
{(session) => <ExtensionSlot path="session.panel" input={{ session }} />}
|
||||
</Show>
|
||||
),
|
||||
actions: () => (
|
||||
<Show when={session()} keyed>
|
||||
{(session) => <ExtensionSlot path="session.panel.actions" input={{ session }} />}
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExtensions = ReturnType<typeof useExtensionPanels>
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
createComponent,
|
||||
createMemo,
|
||||
createRoot,
|
||||
ErrorBoundary,
|
||||
For,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type JSX,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { PanelProvider, PluginProvider, NativeSurfaceProvider } from "@opencode/plugin/desktop/solid"
|
||||
import type { PanelInput, SlotMap, SlotPath } from "@opencode/plugin/desktop/context"
|
||||
import { emptySlotted } from "@opencode/plugin/slots"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useOptionalDesktopExtensions, type Contribution } from "./provider"
|
||||
import { extensionTabKey } from "./keys"
|
||||
import { ExtensionNativeSurface } from "./native-surface"
|
||||
|
||||
export function ExtensionSlot<Path extends SlotPath>(props: ParentProps<{ path: Path; input?: SlotMap[Path] }>) {
|
||||
const host = useOptionalDesktopExtensions()
|
||||
if (!host) return props.children
|
||||
const language = useLanguage()
|
||||
const slotted = createMemo(() => host.resolved().slotted.get(props.path) ?? emptySlotted<Contribution["render"]>())
|
||||
const contribution = (claim: Contribution) => (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => {
|
||||
onMount(() =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: `${claim.plugin}: ${String(error)}`,
|
||||
}),
|
||||
)
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<PluginProvider value={claim.render.context}>
|
||||
<NativeSurfaceProvider
|
||||
render={(surface) => <ExtensionNativeSurface extensionID={claim.plugin} id={surface.id} />}
|
||||
>
|
||||
<Show
|
||||
when={props.path === "session.panel"}
|
||||
fallback={createComponent(claim.render.render as (input: object) => JSX.Element, props.input ?? {})}
|
||||
>
|
||||
<PanelProvider
|
||||
value={{
|
||||
get session() {
|
||||
return (props.input as PanelInput).session
|
||||
},
|
||||
register(panel) {
|
||||
const owner = getOwner()
|
||||
const session = (props.input as PanelInput).session
|
||||
onCleanup(
|
||||
host.register({
|
||||
key: extensionTabKey(claim.plugin, panel.id),
|
||||
plugin: claim.plugin,
|
||||
session,
|
||||
props: panel,
|
||||
render: () => {
|
||||
const mounted = createRoot((dispose) => ({ dispose, view: panel.children }), owner)
|
||||
onCleanup(mounted.dispose)
|
||||
return mounted.view
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{createComponent(claim.render.render as (input: object) => JSX.Element, props.input ?? {})}
|
||||
</PanelProvider>
|
||||
</Show>
|
||||
</NativeSurfaceProvider>
|
||||
</PluginProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<For each={slotted().before}>{contribution}</For>
|
||||
<Show
|
||||
when={slotted().replace}
|
||||
keyed
|
||||
fallback={
|
||||
<>
|
||||
<For each={slotted().prepend}>{contribution}</For>
|
||||
{props.children}
|
||||
<For each={slotted().append}>{contribution}</For>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{contribution}
|
||||
</Show>
|
||||
<For each={slotted().after}>{contribution}</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
|
||||
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
|
||||
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
|
||||
export type BrowserPaneLayout = {
|
||||
tabID: Browser.TabID
|
||||
visible: boolean
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
background?: readonly [number, number, number, number]
|
||||
radius?: number
|
||||
}
|
||||
|
||||
export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type BrowserPanePlatform = {
|
||||
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { BrowserPanePlatform } from "./browser-pane"
|
||||
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import type { Plugin } from "@opencode/plugin/desktop"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -120,8 +121,8 @@ type PlatformBase = {
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
extensions?: DesktopExtension.Transport
|
||||
extensionPlugins?: readonly Plugin.Definition[]
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
type Server = ReturnType<typeof useServer>
|
||||
|
||||
export type BrowserAttachment = {
|
||||
registration?: BrowserPaneRegistration
|
||||
browser: BrowserPaneState
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Live = {
|
||||
server: Server
|
||||
sessionID: string
|
||||
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
|
||||
tab?: string
|
||||
registration?: BrowserPaneRegistration
|
||||
retry?: ReturnType<typeof setTimeout>
|
||||
attempts: number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
|
||||
// browser survive visiting Settings or another tab and close when the session tab or the setting does.
|
||||
export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({
|
||||
name: "BrowserAttachments",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
)
|
||||
const close = (id: string) => {
|
||||
live.get(id)?.dispose()
|
||||
live.delete(id)
|
||||
setStore(id, undefined)
|
||||
}
|
||||
createEffect(() => {
|
||||
const on = enabled()
|
||||
const tabs = shellTabs.store
|
||||
// The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments.
|
||||
Object.keys(store).forEach((id) => {
|
||||
const entry = live.get(id)
|
||||
if (!entry) return
|
||||
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
|
||||
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
|
||||
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
|
||||
if (current) entry.tab = tabKey(current)
|
||||
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
|
||||
if (on && owned && !entry.server.health?.incompatible) return
|
||||
close(id)
|
||||
})
|
||||
})
|
||||
onCleanup(() => Array.from(live.keys()).forEach(close))
|
||||
|
||||
return {
|
||||
enabled,
|
||||
supported: (server: Server) => !unsupported[server.key],
|
||||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
if (live.has(id)) return
|
||||
const pane = platform.browserPane
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
|
||||
live.set(id, entry)
|
||||
setStore(id, { browser: null })
|
||||
const register = () => {
|
||||
if (entry.registration || live.get(id) !== entry) return
|
||||
// The server's shared transport follows a restarted sidecar's port whether or not any route
|
||||
// for this session is mounted; the connection captured at attach time may predate it.
|
||||
const endpoint = { ...server.conn.http, url: server.ctx.sdk.url }
|
||||
const registration = pane.register({ sessionID, endpoint }, (event) => {
|
||||
if (live.get(id) !== entry) return
|
||||
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
|
||||
if (event.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
return close(id)
|
||||
}
|
||||
if (event.error === "browser.pane.replaced") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
return setStore(id, {
|
||||
registration: undefined,
|
||||
browser: null,
|
||||
error: language.t("session.browser.replaced"),
|
||||
})
|
||||
}
|
||||
// The desktop dropped the attachment (server restart, attach race). Re-register so the
|
||||
// agent's browser tool comes back without a reload.
|
||||
if (event.error === "browser.pane.registration.closed") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
setStore(id, { registration: undefined, browser: null, error: undefined })
|
||||
entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++))
|
||||
return
|
||||
}
|
||||
if (event.state) entry.attempts = 0
|
||||
batch(() => {
|
||||
setStore(id, "browser", reconcile(event.state))
|
||||
setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined)
|
||||
})
|
||||
})
|
||||
entry.registration = registration
|
||||
setStore(id, { registration, browser: null, error: undefined })
|
||||
}
|
||||
// A new session appears in the UI before its server-side creation finishes. The listener
|
||||
// belongs to this provider, not to the route effect that happened to call attach().
|
||||
const data = server.ctx.data
|
||||
const unsubscribe = runWithOwner(owner, () =>
|
||||
data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
}),
|
||||
)
|
||||
if (!data.session.creating(sessionID)) register()
|
||||
entry.dispose = () => {
|
||||
unsubscribe?.()
|
||||
clearTimeout(entry.retry)
|
||||
entry.registration?.close()
|
||||
}
|
||||
},
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
focus.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
const registration = live.get(key(server, sessionID))?.registration
|
||||
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
|
||||
return registration.command(command)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import type { SessionModel } from "../model"
|
||||
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
|
||||
import { useBrowserAttachments } from "./attachments"
|
||||
|
||||
export function createSessionBrowser(session: SessionModel) {
|
||||
const attachments = useBrowserAttachments()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [local, setLocal] = createStore({ error: undefined as string | undefined })
|
||||
const attachment = () => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
return sessionID ? attachments.state(server, sessionID) : undefined
|
||||
}
|
||||
const available = createMemo(
|
||||
() =>
|
||||
attachments.enabled() &&
|
||||
attachments.supported(server) &&
|
||||
!!session.identity.sessionID() &&
|
||||
!server.health?.incompatible &&
|
||||
session.isDesktop(),
|
||||
)
|
||||
const attached = () => attachment()?.registration !== undefined
|
||||
const browserTabs = createMemo(
|
||||
() =>
|
||||
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
|
||||
[],
|
||||
)
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
setLocal("error", undefined)
|
||||
const owner = session.ownership.capture()
|
||||
void attachments.command(server, sessionID, command).catch(() => {
|
||||
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().active(),
|
||||
(active) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
|
||||
if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().all(),
|
||||
(current, previous) => {
|
||||
previous
|
||||
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
|
||||
.forEach((key) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
|
||||
if (tab) command({ type: "tabs.close", tabID: tab.id })
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
// Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last
|
||||
// inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms.
|
||||
createEffect(
|
||||
on(
|
||||
() => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)),
|
||||
(ids, previous) => {
|
||||
if (!ids) return
|
||||
const known = new Set(previous ?? [])
|
||||
batch(() => {
|
||||
const tabs = session.layout.tabs()
|
||||
tabs
|
||||
.all()
|
||||
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
|
||||
.forEach(tabs.close)
|
||||
const current = tabs.all()
|
||||
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
|
||||
if (added.length) tabs.setAll([...current, ...added])
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
return {
|
||||
available,
|
||||
attached,
|
||||
opened: () => attached() && browserTabs().length > 0,
|
||||
state: () => attachment()?.browser ?? null,
|
||||
tabs: browserTabs,
|
||||
active: () =>
|
||||
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
|
||||
browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ??
|
||||
browserTabs()[0],
|
||||
error: () => local.error ?? attachment()?.error,
|
||||
registration: () => attachment()?.registration,
|
||||
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
|
||||
open: () => command({ type: "tabs.open" }),
|
||||
command,
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: {
|
||||
registration: BrowserPaneRegistration
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
visible: boolean
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const state = props.browser.active
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
const covered = (rect: DOMRect) =>
|
||||
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
if (!tab) {
|
||||
props.registration.setLayout()
|
||||
return
|
||||
}
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const zoom = platform.webviewZoom?.() ?? 1
|
||||
const left = Math.round(rect.left * zoom)
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
|
||||
).backgroundColor
|
||||
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
|
||||
if (next !== layout) {
|
||||
layout = next
|
||||
// Let the browser resolve the semantic backdrop color, including custom
|
||||
// themes using color formats that Electron's color parser cannot read.
|
||||
if (paint) {
|
||||
paint.clearRect(0, 0, 1, 1)
|
||||
paint.fillStyle = color
|
||||
paint.fillRect(0, 0, 1, 1)
|
||||
}
|
||||
const rgba = paint?.getImageData(0, 0, 1, 1).data
|
||||
props.registration.setLayout({
|
||||
tabID: tab.id,
|
||||
visible,
|
||||
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
||||
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
|
||||
radius: Math.round(10 * zoom),
|
||||
})
|
||||
}
|
||||
}
|
||||
const tick = () => {
|
||||
frame = undefined
|
||||
measure()
|
||||
if (performance.now() < until) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
const schedule = (duration = 0) => {
|
||||
until = Math.max(until, performance.now() + duration)
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
() => platform.webviewZoom?.(),
|
||||
() => dialog.active,
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
],
|
||||
() => schedule(300),
|
||||
),
|
||||
)
|
||||
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
|
||||
// animation frame keeps the native view in step with a pane drag.
|
||||
createResizeObserver(() => surface, measure)
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// Floating content portals directly into <body>; keep measuring briefly so
|
||||
// the positioner has settled before the overlap check runs.
|
||||
const portals = new MutationObserver(() => schedule(300))
|
||||
portals.observe(document.body, { childList: true })
|
||||
onCleanup(() => portals.disconnect())
|
||||
const appearance = new MutationObserver(() => schedule(300))
|
||||
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
|
||||
onCleanup(() => appearance.disconnect())
|
||||
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
props.registration.setLayout()
|
||||
})
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { Mark } from "@opencode/ui/logo"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
@@ -21,8 +22,6 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
|
||||
const reviewTabID = "session-side-panel-review-tab"
|
||||
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
||||
const browserTabID = "session-side-panel-browser-tab"
|
||||
const browserTabPanelID = "session-side-panel-browser-tabpanel"
|
||||
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
|
||||
import { SessionContextTab } from "@/session/files/session-context-tab"
|
||||
import { SortableTab } from "@/session/files/tab"
|
||||
@@ -36,8 +35,6 @@ import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
shouldShowFileTree,
|
||||
@@ -46,8 +43,8 @@ import {
|
||||
import { setSessionHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
|
||||
import { SessionBrowserPane } from "@/session/browser/pane"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
import type { SessionExtensions } from "@/extensions/session"
|
||||
import { isExtensionTab } from "@/extensions/keys"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
@@ -58,6 +55,7 @@ function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
extensions: SessionExtensions
|
||||
canReview: boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
@@ -72,7 +70,6 @@ export function SessionSidePanel(props: {
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -81,6 +78,7 @@ export function SessionSidePanel(props: {
|
||||
const command = useCommand()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
const extensions = props.extensions
|
||||
const projectDirectory = createMemo(() => sdk().directory)
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -173,7 +171,7 @@ export function SessionSidePanel(props: {
|
||||
review: reviewTab,
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => true,
|
||||
browser: props.browser.attached,
|
||||
extensions: extensions.keys,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
@@ -225,7 +223,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isExtensionTab(active)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
@@ -365,21 +363,22 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
<Match when={isExtensionTab(tab)}>
|
||||
<Show when={extensions.panels().find((panel) => panel.key === tab)}>
|
||||
{(panel) => (
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
onTabClose={() => props.browser.close(item().id)}
|
||||
id={`${browserTabID}-${item().id}`}
|
||||
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
|
||||
onTabClose={tabs().close}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{item().title || language.t("session.tab.browser")}
|
||||
<Show when={panel().props.loading} fallback={panel().props.icon}>
|
||||
<Loader />
|
||||
</Show>
|
||||
<span class="max-w-40 truncate" dir="auto">
|
||||
{panel().props.title}
|
||||
</span>
|
||||
<Show when={panel().props.badge}>{panel().props.badge}</Show>
|
||||
</div>
|
||||
</SortableTab>
|
||||
)}
|
||||
@@ -436,7 +435,7 @@ export function SessionSidePanel(props: {
|
||||
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{/* With only files to add, the plus stays a one-click "Open file" button. */}
|
||||
<Show
|
||||
when={props.browser.available()}
|
||||
when={extensions.hasActions()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
value={
|
||||
@@ -491,12 +490,7 @@ export function SessionSidePanel(props: {
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={props.browser.open}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
{extensions.actions()}
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
@@ -552,28 +546,17 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={
|
||||
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
|
||||
}
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
|
||||
inert={!isSessionBrowserTab(activeTab()) || undefined}
|
||||
>
|
||||
<Show when={props.browser.registration()} keyed>
|
||||
{(registration) => (
|
||||
<SessionBrowserPane
|
||||
registration={registration}
|
||||
browser={props.browser}
|
||||
visible={isSessionBrowserTab(activeTab())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={extensions.panels().find((panel) => panel.key === activeTab())} keyed>
|
||||
{(panel) => (
|
||||
<div
|
||||
role="tabpanel"
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden flex flex-col"
|
||||
aria-label={panel.props.title}
|
||||
>
|
||||
{panel.render()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={fileBrowserMounted()}>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
SESSION_BROWSER_TAB,
|
||||
sessionBrowserTab,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
createOpenReviewFile,
|
||||
createOpenSessionFileTab,
|
||||
@@ -12,6 +10,7 @@ import {
|
||||
getTabReorderIndex,
|
||||
shouldShowFileTree,
|
||||
} from "./helpers"
|
||||
import { extensionTabKey } from "@/extensions/keys"
|
||||
|
||||
describe("shouldShowFileTree", () => {
|
||||
test("does not reserve space for a disabled file tree", () => {
|
||||
@@ -236,35 +235,36 @@ describe("createSessionTabs", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes one browser tab without treating it as a file tab", () => {
|
||||
test("exposes one extension panel without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
|
||||
const panel = extensionTabKey("example", "panel")
|
||||
const tabs = createMemo(() => ({ active: () => panel, all: () => [panel] }))
|
||||
const result = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: () => undefined,
|
||||
normalizeTab: (tab) => tab,
|
||||
browser: () => true,
|
||||
extensions: () => [panel],
|
||||
})
|
||||
|
||||
expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB])
|
||||
expect(result.panelTabs()).toEqual([panel])
|
||||
expect(result.openedTabs()).toEqual([])
|
||||
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
|
||||
expect(result.activeTab()).toBe(panel)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
|
||||
expect(result.closableTab()).toBe(panel)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => {
|
||||
const first = sessionBrowserTab("first")
|
||||
const second = sessionBrowserTab("second")
|
||||
test("keeps extension panels in layout order beside files and drops unavailable contributions", () => {
|
||||
const first = extensionTabKey("example", "first")
|
||||
const second = extensionTabKey("example", "second")
|
||||
const input = {
|
||||
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
|
||||
pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
|
||||
normalizeTab: (tab: string) => tab,
|
||||
}
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => true })
|
||||
const result = createSessionTabs({ ...input, extensions: () => [first, second] })
|
||||
expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second])
|
||||
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe(second)
|
||||
@@ -273,7 +273,7 @@ describe("createSessionTabs", () => {
|
||||
dispose()
|
||||
})
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => false })
|
||||
const result = createSessionTabs({ ...input, extensions: () => [] })
|
||||
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe("file://src/a.ts")
|
||||
expect(result.closableTab()).toBe("file://src/a.ts")
|
||||
|
||||
@@ -2,14 +2,10 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { isExtensionTab } from "@/extensions/keys"
|
||||
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
sessionBrowserTab,
|
||||
isSessionBrowserTab,
|
||||
} from "@/shell/state/session-tabs"
|
||||
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
|
||||
const emptyTabs: string[] = []
|
||||
|
||||
@@ -25,7 +21,7 @@ type TabsInput = {
|
||||
review?: Accessor<boolean>
|
||||
hasReview?: Accessor<boolean>
|
||||
fileBrowser?: Accessor<boolean>
|
||||
browser?: Accessor<boolean>
|
||||
extensions?: Accessor<readonly string[]>
|
||||
}
|
||||
|
||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||
@@ -36,7 +32,6 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const review = input.review ?? (() => false)
|
||||
const hasReview = input.hasReview ?? (() => false)
|
||||
const fileBrowser = input.fileBrowser ?? (() => false)
|
||||
const browser = input.browser ?? (() => false)
|
||||
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
||||
const openFileOpen = createMemo(
|
||||
() =>
|
||||
@@ -51,7 +46,8 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (tab === "context" || tab === "review") return []
|
||||
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
|
||||
if (isExtensionTab(tab)) return input.extensions?.().includes(tab) ? [tab] : []
|
||||
if (tab !== SESSION_OPEN_FILE_TAB && !input.pathFromTab(tab)) return []
|
||||
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
if (seen.has(value)) return []
|
||||
@@ -63,15 +59,15 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isExtensionTab(tab)),
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const activeTab = createMemo(() => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (active && isExtensionTab(active) && input.extensions?.().includes(active)) return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (active === "review" && review()) return active
|
||||
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
||||
|
||||
@@ -88,9 +84,9 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
})
|
||||
const closableTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (active && isExtensionTab(active) && input.extensions?.().includes(active)) return active
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useData } from "@/runtime/server/current"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useBrowserAttachments } from "./browser/attachments"
|
||||
import { createSessionTabs } from "./helpers"
|
||||
import {
|
||||
normalizeSessionTab,
|
||||
@@ -19,6 +18,7 @@ import { useSessionLayout } from "./session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useOptionalDesktopExtensions } from "@/extensions/provider"
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
const emptyUserMessages: SessionMessageUser[] = []
|
||||
@@ -29,7 +29,7 @@ export function useSessionModel() {
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const shellTabs = useTabs()
|
||||
const attachments = useBrowserAttachments()
|
||||
const extensions = useOptionalDesktopExtensions()
|
||||
const layout = useSessionLayout()
|
||||
const location = useWorkspaceLocation()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -83,12 +83,11 @@ export function useSessionModel() {
|
||||
normalizeTab,
|
||||
review: isDesktop,
|
||||
hasReview: canReview,
|
||||
extensions: () =>
|
||||
extensions?.state.panels
|
||||
.filter((panel) => panel.session.sessionID === sessionID() && panel.session.server.id === server.key)
|
||||
.map((panel) => panel.key) ?? [],
|
||||
fileBrowser: () => isDesktop() && !!sessionID(),
|
||||
// Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows.
|
||||
browser: () => {
|
||||
const id = sessionID()
|
||||
return !!id && attachments.state(server, id)?.registration !== undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SessionSidePanel } from "../files/session-side-panel"
|
||||
import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
import type { SessionExtensions } from "@/extensions/session"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
@@ -147,12 +147,13 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
extensions: SessionExtensions
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
extensions={props.extensions}
|
||||
canReview={props.review.canReview()}
|
||||
diffs={props.review.diffs()}
|
||||
diffsReady={props.review.ready()}
|
||||
@@ -173,7 +174,6 @@ export function SessionDesktopReview(props: {
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { useExtensionPanels } from "@/extensions/session"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -48,7 +48,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const extensions = useExtensionPanels({
|
||||
serverID: () => server.key,
|
||||
sessionID: session.identity.sessionID,
|
||||
tabs: session.layout.tabs,
|
||||
open: () => session.layout.view().reviewPanel.open(),
|
||||
})
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
@@ -288,6 +293,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={isDesktop()}>{extensions.declarations()}</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
|
||||
@@ -379,7 +385,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview review={review} extensions={extensions} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Component, Show } from "solid-js"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import "@/settings/settings.css"
|
||||
import { ExtensionSlot } from "@/extensions/slot"
|
||||
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
|
||||
export const SettingsExperimental: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -31,22 +30,7 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<ExtensionSlot path="settings.experimental" />
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export interface SettingsRowProps {
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div data-component="settings-row">
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title">{props.title}</div>
|
||||
<div data-slot="settings-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { SettingsRow } from "@opencode/ui/layout"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
|
||||
import { DesktopExtensionsProvider } from "@/extensions/provider"
|
||||
import { ExtensionSlot } from "@/extensions/slot"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
@@ -79,9 +80,11 @@ function AppLayout(props: ParentProps) {
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
<DesktopExtensionsProvider>
|
||||
<ExtensionSlot path="app">
|
||||
<Shell>{props.children}</Shell>
|
||||
</ExtensionSlot>
|
||||
</DesktopExtensionsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
</Show>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
export const SESSION_BROWSER_TAB = "browser"
|
||||
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
|
||||
export const isSessionBrowserTab = (tab: string | undefined) =>
|
||||
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
|
||||
|
||||
export type SessionTabs = {
|
||||
active?: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { ExtensionSlot } from "@/extensions/slot"
|
||||
|
||||
type Registration = {
|
||||
active: () => boolean
|
||||
@@ -45,7 +46,9 @@ export function TitlebarRightMount(props: { vertical?: boolean }) {
|
||||
ref={slot.setMount}
|
||||
id="opencode-titlebar-right"
|
||||
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
|
||||
/>
|
||||
>
|
||||
<ExtensionSlot path="titlebar.actions" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,10 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/plugin-browser-desktop": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import BrowserExtension from "@opencode/plugin-browser-desktop/main"
|
||||
|
||||
export const mainExtensions: readonly MainPlugin.Entry[] = [BrowserExtension]
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { NodeHttpClient } from "@effect/platform-node"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import { createLifecycle } from "@opencode/plugin/desktop/lifecycle"
|
||||
import { CallError, decode, encode } from "@opencode/plugin/desktop/rpc"
|
||||
import { Effect, ManagedRuntime, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { createSurfaces } from "./surfaces"
|
||||
|
||||
export function createMainExtensionHost(
|
||||
plugins: readonly MainPlugin.Entry[],
|
||||
publish: (win: BrowserWindow, event: DesktopExtension.Event) => void,
|
||||
) {
|
||||
const windows = new Map<BrowserWindow, ReturnType<typeof windowHost>>()
|
||||
const host = (win: BrowserWindow) => {
|
||||
const previous = windows.get(win)
|
||||
if (previous) return previous
|
||||
const result = windowHost(win)
|
||||
windows.set(win, result)
|
||||
win.once("closed", () => {
|
||||
result.dispose()
|
||||
windows.delete(win)
|
||||
})
|
||||
return result
|
||||
}
|
||||
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
|
||||
return {
|
||||
configure(win: BrowserWindow, servers: readonly DesktopExtension.Endpoint[]) {
|
||||
host(win).configure(servers)
|
||||
},
|
||||
call(win: BrowserWindow, input: DesktopExtension.Call) {
|
||||
return host(win).call(input)
|
||||
},
|
||||
cancel(win: BrowserWindow, extensionID: string, requestID: string) {
|
||||
host(win).cancel(extensionID, requestID)
|
||||
},
|
||||
surface(win: BrowserWindow, extensionID: string, surfaceID: string, layout?: DesktopExtension.Layout) {
|
||||
host(win).surfaces.layout(extensionID, surfaceID, layout)
|
||||
},
|
||||
release(win: BrowserWindow, extensionID: string) {
|
||||
host(win).release(extensionID)
|
||||
},
|
||||
async dispose() {
|
||||
windows.forEach((value) => value.dispose())
|
||||
windows.clear()
|
||||
await runtime.dispose()
|
||||
},
|
||||
}
|
||||
|
||||
function windowHost(win: BrowserWindow) {
|
||||
const servers = new Map<string, DesktopExtension.Endpoint>()
|
||||
const instances = new Map<
|
||||
string,
|
||||
{
|
||||
lifecycle: ReturnType<typeof createLifecycle>
|
||||
handlers: ReturnType<MainPlugin.Entry["setup"]>
|
||||
definition: MainPlugin.Entry
|
||||
}
|
||||
>()
|
||||
const calls = new Map<string, AbortController>()
|
||||
const surfaces = createSurfaces(win)
|
||||
const release = (extensionID: string) => {
|
||||
const instance = instances.get(extensionID)
|
||||
instances.delete(extensionID)
|
||||
try {
|
||||
instance?.lifecycle.dispose()
|
||||
} finally {
|
||||
surfaces.release(extensionID)
|
||||
}
|
||||
}
|
||||
const instance = (id: string) => {
|
||||
const previous = instances.get(id)
|
||||
if (previous) return previous
|
||||
const definition = plugins.find((plugin) => plugin.id === id)
|
||||
if (!definition) throw new CallError("rpc.unavailable", `Desktop extension unavailable: ${id}`)
|
||||
const lifecycle = createLifecycle()
|
||||
const context: MainPlugin.Context = {
|
||||
window: win,
|
||||
lifecycle,
|
||||
client(serverID) {
|
||||
const endpoint = servers.get(serverID)
|
||||
if (!endpoint) return Promise.reject(new Error("Desktop server is unavailable"))
|
||||
return runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const authorization = endpoint.password
|
||||
? `Basic ${Buffer.from(`${endpoint.username ?? "opencode"}:${endpoint.password}`).toString("base64")}`
|
||||
: SidecarCredentials.authorization(SidecarCredentials.get(), endpoint.url)
|
||||
return yield* OpenCode.make({ baseUrl: endpoint.url }).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
authorization
|
||||
? HttpClient.mapRequest(http, HttpClientRequest.setHeader("authorization", authorization))
|
||||
: http,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
surfaces: { register: (view) => surfaces.register(id, view) },
|
||||
async emit(contract, name, data) {
|
||||
if (lifecycle.signal.aborted) return
|
||||
const event = contract.events[name]
|
||||
if (!event) throw new Error(`Unknown desktop event: ${name}`)
|
||||
publish(win, { extensionID: id, rpcID: contract.id, name, data: await encode(event.schema, data) })
|
||||
},
|
||||
}
|
||||
try {
|
||||
const value = { lifecycle, definition, handlers: definition.setup(context) }
|
||||
instances.set(id, value)
|
||||
return value
|
||||
} catch (error) {
|
||||
lifecycle.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return {
|
||||
surfaces,
|
||||
release,
|
||||
configure(values: readonly DesktopExtension.Endpoint[]) {
|
||||
servers.clear()
|
||||
values.forEach((server) => servers.set(server.id, server))
|
||||
},
|
||||
cancel(id: string, requestID: string) {
|
||||
calls.get(`${id}/${requestID}`)?.abort()
|
||||
},
|
||||
async call(input: DesktopExtension.Call): Promise<Schema.Json> {
|
||||
const key = `${input.extensionID}/${input.requestID}`
|
||||
const controller = new AbortController()
|
||||
calls.set(key, controller)
|
||||
try {
|
||||
const current = instance(input.extensionID)
|
||||
const method =
|
||||
current.definition.rpc.id === input.rpcID ? current.definition.rpc.methods[input.method] : undefined
|
||||
const handler = current.handlers[input.method]
|
||||
if (!method || !handler) throw new CallError("rpc.method_not_found", "Unknown desktop extension method")
|
||||
const value = await decode(method.input, input.input).catch((error) => {
|
||||
throw new CallError("rpc.invalid_input", String(error))
|
||||
})
|
||||
const output = await handler(value, {
|
||||
signal: AbortSignal.any([controller.signal, current.lifecycle.signal]),
|
||||
error(type, message, data): never {
|
||||
throw new CallError(type, message, data)
|
||||
},
|
||||
})
|
||||
return { ok: true, output: await encode(method.output, output) }
|
||||
} catch (error) {
|
||||
return Schema.decodeUnknownSync(Schema.Json)({
|
||||
ok: false,
|
||||
error: {
|
||||
type: error instanceof CallError ? error.type : "rpc.internal",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
...(error instanceof CallError && error.data !== undefined
|
||||
? { data: Schema.decodeUnknownSync(Schema.Json)(error.data) }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
calls.delete(key)
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
calls.forEach((call) => call.abort())
|
||||
Array.from(instances.keys()).forEach(release)
|
||||
surfaces.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ImageView, screen, type BrowserWindow, type View } from "electron"
|
||||
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import { createCornerImages } from "../native/corners"
|
||||
|
||||
export function createSurfaces(win: BrowserWindow) {
|
||||
const entries = new Map<string, { extensionID: string; view: View; corners: ImageView[]; key: string }>()
|
||||
const remove = (id: string) => {
|
||||
const entry = entries.get(id)
|
||||
if (!entry) return
|
||||
entries.delete(id)
|
||||
if (win.isDestroyed()) return
|
||||
entry.view.setVisible(false)
|
||||
entry.corners.forEach((corner) => win.contentView.removeChildView(corner))
|
||||
win.contentView.removeChildView(entry.view)
|
||||
}
|
||||
return {
|
||||
register(extensionID: string, view: View) {
|
||||
const id = crypto.randomUUID()
|
||||
view.setBounds({ x: 0, y: 0, width: 1000, height: 700 })
|
||||
view.setVisible(false)
|
||||
win.contentView.addChildView(view)
|
||||
const corners = [new ImageView(), new ImageView()]
|
||||
corners.forEach((corner) => {
|
||||
corner.setVisible(false)
|
||||
win.contentView.addChildView(corner)
|
||||
})
|
||||
entries.set(id, { extensionID, view, corners, key: "" })
|
||||
return { id, dispose: () => remove(id) }
|
||||
},
|
||||
layout(extensionID: string, id: string, layout?: DesktopExtension.Layout) {
|
||||
const entry = entries.get(id)
|
||||
if (!entry || entry.extensionID !== extensionID || win.isDestroyed()) return
|
||||
const bounds = layout?.bounds
|
||||
if (!layout?.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
entry.view.setVisible(false)
|
||||
entry.corners.forEach((corner) => corner.setVisible(false))
|
||||
return
|
||||
}
|
||||
entry.view.setBounds(bounds)
|
||||
const size = Math.min(layout.radius ?? 10, Math.floor(bounds.width / 2), Math.floor(bounds.height / 2))
|
||||
const scale = screen.getDisplayMatching(win.getBounds()).scaleFactor
|
||||
const key = layout.background && size > 0 ? `${layout.background}:${size}:${scale}` : ""
|
||||
if (key && key !== entry.key && layout.background)
|
||||
createCornerImages(layout.background, size, scale).forEach((image, index) =>
|
||||
entry.corners[index].setImage(image),
|
||||
)
|
||||
entry.key = key
|
||||
entry.corners.forEach((corner, index) => {
|
||||
corner.setBounds(
|
||||
{
|
||||
x: bounds.x + (index ? bounds.width - size : 0),
|
||||
y: bounds.y + bounds.height - size,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
{ animate: { duration: 0 } },
|
||||
)
|
||||
corner.setVisible(!!key)
|
||||
})
|
||||
entry.view.setVisible(true)
|
||||
},
|
||||
release(extensionID: string) {
|
||||
entries.forEach((entry, id) => {
|
||||
if (entry.extensionID === extensionID) remove(id)
|
||||
})
|
||||
},
|
||||
dispose() {
|
||||
Array.from(entries.keys()).forEach(remove)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,42 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { createBrowserPane } from "../browser-pane"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { isRendererUrl } from "../windows/protocol"
|
||||
import { sender } from "./context"
|
||||
import { createMainExtensionHost } from "../extensions/host"
|
||||
import { mainExtensions } from "../extensions/builtins"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { ExtensionEvent } from "../../shared/ipc-rpc/events"
|
||||
|
||||
export const eventHandlers = EventRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const shutdown = yield* Shutdown.Service
|
||||
const browser = createBrowserPane()
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
const extensions = createMainExtensionHost(mainExtensions, (win, event) =>
|
||||
emitIpcEvent(win.webContents, new ExtensionEvent({ event })),
|
||||
)
|
||||
const stop = Effect.promise(() => extensions.dispose())
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
BrowserPane: ({ request }, context) =>
|
||||
DesktopExtension: ({ request }, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
|
||||
throw new Error("browser.pane.owner.invalid")
|
||||
}
|
||||
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
|
||||
return browser.close(win, request.bindingID)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL()))
|
||||
throw new Error("Desktop extension owner is unavailable")
|
||||
if (request.type === "call") return extensions.call(win, request.call)
|
||||
if (request.type === "cancel") extensions.cancel(win, request.extensionID, request.requestID)
|
||||
if (request.type === "servers") extensions.configure(win, request.servers)
|
||||
if (request.type === "surface")
|
||||
extensions.surface(win, request.extensionID, request.surfaceID, request.layout)
|
||||
if (request.type === "release") extensions.release(win, request.extensionID)
|
||||
return null
|
||||
}).pipe(Effect.orDie),
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { nativeImage } from "electron"
|
||||
|
||||
// Native browser surfaces ignore a parent View's clip path. Cover only the
|
||||
// Native surfaces ignore a parent View's clip path. Cover only the
|
||||
// pixels outside the bottom arcs; never resize or style the page itself.
|
||||
export function createCornerImages(color: readonly [number, number, number, number], radius: number, scale: number) {
|
||||
const size = Math.max(1, Math.round(radius * scale))
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { BrowserPaneEvent } from "@opencode/app/desktop"
|
||||
import type { DesktopMenuAction } from "@opencode/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode/app/wsl/types"
|
||||
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
|
||||
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import type {
|
||||
ClipboardImage,
|
||||
DirectoryPickerOptions,
|
||||
@@ -23,13 +22,9 @@ export type UpdaterAPI = {
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
extensions: DesktopExtension.Transport
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
browserPane: {
|
||||
request(request: BrowserPaneRequest): Promise<void>
|
||||
send(request: BrowserPaneRequest): void
|
||||
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
|
||||
}
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
|
||||
@@ -23,13 +23,30 @@ const updaterHandler = (state: UpdaterState) => {
|
||||
}
|
||||
|
||||
export const api: ElectronAPI = {
|
||||
extensions: {
|
||||
call(input, signal) {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason)
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancel = () => {
|
||||
send("DesktopExtension", {
|
||||
request: { type: "cancel", extensionID: input.extensionID, requestID: input.requestID },
|
||||
})
|
||||
reject(signal?.reason)
|
||||
}
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
void invoke("DesktopExtension", { request: { type: "call", call: input } })
|
||||
.then(resolve, reject)
|
||||
.finally(() => signal?.removeEventListener("abort", cancel))
|
||||
})
|
||||
},
|
||||
onEvent: (callback) => listen("ExtensionEvent", ({ event }) => callback(event)),
|
||||
surface: (extensionID, surfaceID, layout) =>
|
||||
send("DesktopExtension", { request: { type: "surface", extensionID, surfaceID, layout } }),
|
||||
configure: (servers) => send("DesktopExtension", { request: { type: "servers", servers } }),
|
||||
release: (extensionID) => send("DesktopExtension", { request: { type: "release", extensionID } }),
|
||||
},
|
||||
awaitInitialization: () => invoke("AppAwaitInitialization"),
|
||||
reconnectService: () => invoke("AppReconnectService"),
|
||||
browserPane: {
|
||||
request: (request) => invoke("BrowserPane", { request }),
|
||||
send: (request) => send("BrowserPane", { request }),
|
||||
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
|
||||
},
|
||||
wslServers: {
|
||||
getState: () => invoke("WslGetState").then(mutable),
|
||||
subscribe: (cb) => {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
ACCEPTED_FILE_EXTENSIONS,
|
||||
ServerConnection,
|
||||
type Platform,
|
||||
type UpdaterPlatform,
|
||||
} from "@opencode/app/desktop"
|
||||
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode/app/desktop"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
|
||||
import { windowFullscreen } from "../window/fullscreen"
|
||||
@@ -11,6 +6,7 @@ import { DragCancelEvent } from "../../shared/ipc-transport"
|
||||
import { createDesktopFiles } from "./files"
|
||||
import { createDesktopMenuAction } from "./menu"
|
||||
import { createDesktopNotify } from "./notifications"
|
||||
import BrowserExtension from "@opencode/plugin-browser-desktop"
|
||||
import { createDesktopStorage } from "./storage"
|
||||
|
||||
export type DesktopWindowState = {
|
||||
@@ -29,37 +25,10 @@ export function createDesktopPlatform(
|
||||
os,
|
||||
version: windowState.version,
|
||||
windowID: windowState.id,
|
||||
extensions: api.extensions,
|
||||
extensionPlugins: [BrowserExtension],
|
||||
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
|
||||
...createDesktopStorage(api),
|
||||
browserPane: {
|
||||
register(target, onEvent) {
|
||||
const bindingID = crypto.randomUUID()
|
||||
let closed = false
|
||||
const dispose = api.browserPane.onEvent((value) => {
|
||||
if (!closed && value.bindingID === bindingID) onEvent(value.event)
|
||||
})
|
||||
const ready = api.browserPane.request({ type: "register", bindingID, target })
|
||||
// Failures reach the owner through the closed-state event; keep the bare promise handled.
|
||||
void ready.catch(() => undefined)
|
||||
return {
|
||||
setLayout(layout) {
|
||||
if (!closed)
|
||||
void ready
|
||||
.then(() =>
|
||||
api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
dispose()
|
||||
void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
updater,
|
||||
exportDebugLogs: () => api.exportDebugLogs(),
|
||||
setForceFocus: (enabled) => api.setForceFocus(enabled),
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { Schema } from "effect"
|
||||
import { Rpc } from "effect/unstable/rpc"
|
||||
|
||||
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
|
||||
const bindingID = text(128)
|
||||
const endpoint = Schema.Struct({
|
||||
url: text(16_384),
|
||||
username: Schema.optionalKey(text(1_024)),
|
||||
password: Schema.optionalKey(text(4_096)),
|
||||
})
|
||||
const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint })
|
||||
const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
|
||||
const channel = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 }))
|
||||
const layout = Schema.Struct({
|
||||
tabID: Browser.TabID,
|
||||
visible: Schema.Boolean,
|
||||
bounds: Schema.optionalKey(bounds),
|
||||
background: Schema.optionalKey(Schema.Tuple([channel, channel, channel, channel])),
|
||||
radius: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
||||
})
|
||||
export const BrowserPaneRequestSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
|
||||
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
|
||||
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
|
||||
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
|
||||
])
|
||||
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
|
||||
|
||||
export const BrowserPaneEventSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("state"),
|
||||
state: Schema.NullOr(Browser.State),
|
||||
error: Schema.optionalKey(Schema.String),
|
||||
}),
|
||||
])
|
||||
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
|
||||
import { UpdaterStateSchema } from "./updater"
|
||||
import { WslServersEventSchema } from "./wsl"
|
||||
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import { ExtensionRpc } from "./extensions"
|
||||
|
||||
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
|
||||
bindingID: Schema.String,
|
||||
event: BrowserPaneEventSchema,
|
||||
export class ExtensionEvent extends Schema.TaggedClass<ExtensionEvent>()("ExtensionEvent", {
|
||||
event: DesktopExtension.Event,
|
||||
}) {}
|
||||
|
||||
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
|
||||
@@ -46,7 +46,7 @@ export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("Storag
|
||||
}) {}
|
||||
|
||||
export const DesktopEvent = Schema.Union([
|
||||
BrowserPaneEvent,
|
||||
ExtensionEvent,
|
||||
DeepLinksOpened,
|
||||
MenuCommandTriggered,
|
||||
UpdaterStateChanged,
|
||||
@@ -59,4 +59,4 @@ export const DesktopEvent = Schema.Union([
|
||||
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
|
||||
|
||||
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, ExtensionRpc)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import { Schema } from "effect"
|
||||
import { Rpc } from "effect/unstable/rpc"
|
||||
|
||||
export const ExtensionRequest = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("call"), call: DesktopExtension.Call }),
|
||||
Schema.Struct({ type: Schema.Literal("cancel"), extensionID: Schema.String, requestID: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("servers"), servers: Schema.Array(DesktopExtension.Endpoint) }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("surface"),
|
||||
extensionID: Schema.String,
|
||||
surfaceID: Schema.String,
|
||||
layout: Schema.optionalKey(DesktopExtension.Layout),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("release"), extensionID: Schema.String }),
|
||||
])
|
||||
export type ExtensionRequest = typeof ExtensionRequest.Type
|
||||
export const ExtensionRpc = Rpc.make("DesktopExtension", {
|
||||
payload: { request: ExtensionRequest },
|
||||
success: Schema.Json,
|
||||
})
|
||||
@@ -6,9 +6,10 @@ import path from "node:path"
|
||||
import { app, BrowserWindow, nativeImage } from "electron"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { createBrowserPane } from "../../src/main/browser-pane"
|
||||
import { bindIpcEvents, ipcEventStream } from "../../src/main/ipc-events"
|
||||
import { Schema } from "effect"
|
||||
import BrowserExtension from "@opencode/plugin-browser-desktop/main"
|
||||
import { BrowserDesktop } from "@opencode/plugin-browser-desktop/rpc"
|
||||
import { createMainExtensionHost } from "../../src/main/extensions/host"
|
||||
import { Smoke } from "./contract"
|
||||
import { verifyTargets } from "./targets"
|
||||
|
||||
@@ -126,7 +127,6 @@ async function main() {
|
||||
const location = { directory: process.env.SMOKE_SERVER_FILES! }
|
||||
const rpc = client.rpc(Smoke)
|
||||
const session = await client.session.create({ title: "Browser suite", location })
|
||||
const pane = createBrowserPane()
|
||||
const win = new BrowserWindow({ show: false, width: 1100, height: 800, webPreferences: { sandbox: true } })
|
||||
const readyToShow = once(win, "ready-to-show")
|
||||
await win.loadURL("about:blank")
|
||||
@@ -139,28 +139,40 @@ async function main() {
|
||||
const ipcErrors: string[] = []
|
||||
const replaced: string[] = []
|
||||
const inventories = new Map<string, Browser.State | null>()
|
||||
const unbind = await Effect.runPromise(bindIpcEvents(win.webContents.id))
|
||||
const events = Effect.runFork(
|
||||
ipcEventStream(win.webContents.id).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event._tag !== "BrowserPaneEvent") return
|
||||
if (event.event.type === "state") {
|
||||
if (event.event.error === "browser.pane.replaced") replaced.push(event.bindingID)
|
||||
inventories.set(event.bindingID, event.event.state)
|
||||
return
|
||||
}
|
||||
if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID))
|
||||
ipcErrors.push("Focus arrived before its tab inventory")
|
||||
pane.layout(win, event.bindingID, {
|
||||
tabID: event.event.tabID,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 1000, height: 700 },
|
||||
})
|
||||
}),
|
||||
const surfaces = new Map<string, Readonly<Record<string, string>>>()
|
||||
const pane = createMainExtensionHost([BrowserExtension], (_win, payload) => {
|
||||
const event = Schema.decodeUnknownSync(BrowserDesktop.Definition.events.changed.schema)(payload.data)
|
||||
if (event.event.type === "state") {
|
||||
if (event.event.error === "browser.pane.replaced") replaced.push(event.bindingID)
|
||||
inventories.set(event.bindingID, event.event.state)
|
||||
surfaces.set(event.bindingID, event.event.surfaces)
|
||||
return
|
||||
}
|
||||
if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID))
|
||||
ipcErrors.push("Focus arrived before its tab inventory")
|
||||
layout(event.bindingID, event.event.tabID)
|
||||
})
|
||||
pane.configure(win, [{ id: "fixture", url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD }])
|
||||
function layout(bindingID: string, tabID: Browser.TabID) {
|
||||
Object.entries(surfaces.get(bindingID) ?? {}).forEach(([id, surfaceID]) =>
|
||||
pane.surface(
|
||||
win,
|
||||
BrowserExtension.id,
|
||||
surfaceID,
|
||||
id === tabID ? { visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } } : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
async function register(bindingID: string) {
|
||||
const result = await pane.call(win, {
|
||||
extensionID: BrowserExtension.id,
|
||||
rpcID: BrowserDesktop.Definition.id,
|
||||
method: "register",
|
||||
requestID: crypto.randomUUID(),
|
||||
input: { bindingID, sessionID: session.id, serverID: "fixture" },
|
||||
})
|
||||
assert.deepEqual(result, { ok: true, output: null })
|
||||
}
|
||||
const visited = new Set<Browser.Method>()
|
||||
async function call<Name extends Browser.Method>(
|
||||
name: Name,
|
||||
@@ -187,10 +199,7 @@ async function main() {
|
||||
}
|
||||
try {
|
||||
await verifyTargets(win, fixture)
|
||||
await pane.register(win, "suite", {
|
||||
sessionID: session.id,
|
||||
endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD },
|
||||
})
|
||||
await register("suite")
|
||||
const first = await call("tabs.open", { url: fixture })
|
||||
const second = await call("tabs.open", { url: `${fixture}/other`, focus: false })
|
||||
assert.equal((await call("tabs.list", {})).tabs.length, 2)
|
||||
@@ -290,9 +299,9 @@ async function main() {
|
||||
await new Promise(resolve=>{const frame=document.querySelector('iframe'); frame.onload=()=>resolve(null); frame.src=${JSON.stringify(`http://localhost:${address.port}/frame`)};});
|
||||
})()`,
|
||||
})
|
||||
pane.layout(win, "suite", { tabID, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
|
||||
layout("suite", tabID)
|
||||
await call("tabs.focus", { tabID: second.id })
|
||||
pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
|
||||
layout("suite", second.id)
|
||||
const snap = await call("snapshot", { tabID, boxes: true })
|
||||
const ref = (text: string) => {
|
||||
const match = snap.content
|
||||
@@ -546,18 +555,13 @@ async function main() {
|
||||
[],
|
||||
)
|
||||
assert.deepEqual(ipcErrors, [])
|
||||
await pane.register(win, "replacement", {
|
||||
sessionID: session.id,
|
||||
endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD },
|
||||
})
|
||||
await register("replacement")
|
||||
await until(async () => replaced.includes("suite"))
|
||||
console.log(
|
||||
`PASS ${visited.size} browser operations over physical authenticated HTTP, including file bytes in both directions`,
|
||||
)
|
||||
} finally {
|
||||
await pane.dispose()
|
||||
await Effect.runPromise(Fiber.interrupt(events))
|
||||
await Effect.runPromise(unbind)
|
||||
win.destroy()
|
||||
web.closeAllConnections()
|
||||
await new Promise<void>((resolve) => web.close(() => resolve()))
|
||||
|
||||
@@ -2,16 +2,19 @@ import assert from "node:assert/strict"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { Schema } from "effect"
|
||||
import { createBrowserPage } from "../../src/main/browser-chromium"
|
||||
import { createCornerImages } from "../../src/main/browser/corners"
|
||||
import { createBrowserPage } from "@opencode/plugin-browser-desktop/page"
|
||||
import { createCornerImages } from "../../src/main/native/corners"
|
||||
import { createSurfaces } from "../../src/main/extensions/surfaces"
|
||||
|
||||
export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
const children = win.contentView.children.length
|
||||
const surfaces = createSurfaces(win)
|
||||
const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`)
|
||||
const page = createBrowserPage(win, {
|
||||
id: tabID,
|
||||
partition: `target-test-${crypto.randomUUID()}`,
|
||||
network: null,
|
||||
surfaces: { register: (view) => surfaces.register("test", view) },
|
||||
publish() {},
|
||||
fail() {
|
||||
throw new Error("Target test page failed")
|
||||
@@ -46,8 +49,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
{ x: 30, y: 40, width: 500, height: 300 },
|
||||
{ x: 50, y: 60, width: 600, height: 400 },
|
||||
]) {
|
||||
page.layout(bounds, [255, 255, 255, 255], 10)
|
||||
page.setVisible(true)
|
||||
surfaces.layout("test", page.surfaceID, { visible: true, bounds, background: [255, 255, 255, 255], radius: 10 })
|
||||
await page.contents.executeJavaScript(
|
||||
"new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))))",
|
||||
)
|
||||
@@ -57,7 +59,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
height: bounds.height,
|
||||
})
|
||||
assert.equal(win.contentView.children.length, children + 3)
|
||||
page.setVisible(false)
|
||||
surfaces.layout("test", page.surfaceID)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
}
|
||||
await execute({ type: "navigate", tabID, url })
|
||||
@@ -119,6 +121,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
assert(retained.resources.includes(url + "/"))
|
||||
} finally {
|
||||
await page.dispose()
|
||||
surfaces.dispose()
|
||||
assert.equal(win.contentView.children.length, children)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Built-in Desktop browser extension (draft)
|
||||
|
||||
This package is the Desktop companion to `@opencode/plugin-browser`. It imports the public Plugin, Client, Schema, UI and Electron APIs. Production code does not import App, Desktop, Core or Server internals.
|
||||
|
||||
- `.` is the renderer plugin: settings, availability, attachment lifetime, dynamic panel instances, and the browser toolbar.
|
||||
- `./main` is the trusted native plugin: Chromium, CDP, diagnostics, file capture and remote networking.
|
||||
- `./rpc` is its local renderer/main contract. The existing server-side `@opencode/plugin-browser/rpc` contract remains unchanged.
|
||||
|
||||
The host owns tab layout, selection, closing, keyboard interaction, and native-surface bounds/clipping/occlusion. The extension owns browser pages and protocol behavior. Its UI is composed from shared OpenCode components, including `Panel`, `NativeSurface`, `Toolbar`, `TextInput`, `IconButton`, `Switch` and `SettingsRow`.
|
||||
|
||||
## Ownership
|
||||
|
||||
Every visited session attaches eagerly while the extension's setting allows it. Attachments are owned by the shell tab and survive Settings and session-route changes, enabling agent-initiated browsing while the UI is hidden. Removing an owning shell tab or disabling the setting closes its attachments. A replacement by another Desktop is terminal rather than a reason to reclaim ownership.
|
||||
|
||||
The host resolves an authenticated Node-side client for the current server identity on each attachment attempt. Chromium requests still use the server-side tunnel. Request IDs, cancellation, state acknowledgements, explicit tab targeting, and bounded file transfer retain the existing browser protocol semantics.
|
||||
|
||||
## Exploration notes
|
||||
|
||||
This draft is statically registered as a built-in renderer and main extension. Third-party installation/discovery is a separate loader concern. The new extension-owned setting defaults off; the old host `experimentalBrowser` value is retained in storage, and a migration must be agreed before this becomes a production change. Existing host translation keys are reused byte-for-byte; plugin-owned translation catalogs are an API follow-up.
|
||||
|
||||
## Checks
|
||||
|
||||
- `bun typecheck` and `bun test` run from this package.
|
||||
- `packages/desktop/test/browser-native.test.ts` exercises this package through the generic main extension host, the actual server plugin and real Chromium.
|
||||
- The native suite covers all 44 browser operations, state-publication retries, file bytes crossing separate server/Desktop storage, and replacement handling.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@opencode/plugin-browser-desktop",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./main": "./src/main.ts",
|
||||
"./rpc": "./src/rpc.ts",
|
||||
"./page": "./src/page.ts",
|
||||
"./native/*": "./src/native/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"lighthouse": "13.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"puppeteer-core": "25.9.0"
|
||||
}
|
||||
}
|
||||
+33
-61
@@ -1,18 +1,12 @@
|
||||
import type { BrowserPaneCommand, BrowserPaneLayout, BrowserPaneTarget } from "@opencode/app/desktop"
|
||||
import { NodeHttpClient } from "@effect/platform-node"
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import { SessionID } from "@opencode/schema/session-id"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Deferred, Effect, ManagedRuntime, Queue, Schedule, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
|
||||
import { createBrowserPage, type BrowserPage } from "./browser-chromium"
|
||||
import { browserFailure } from "./browser/errors"
|
||||
import { createBrowserNetwork, type BrowserNetwork } from "./browser/network"
|
||||
import { destinationOrigin } from "./browser/policy"
|
||||
import { emitIpcEvent } from "./ipc-events"
|
||||
import { SidecarCredentials } from "./service/sidecar-credentials"
|
||||
import { Deferred, Effect, Layer, ManagedRuntime, Queue, Schedule, Schema, Stream } from "effect"
|
||||
import { createBrowserPage, type BrowserPage } from "./page"
|
||||
import { browserFailure } from "./native/errors"
|
||||
import { createBrowserNetwork, type BrowserNetwork } from "./native/network"
|
||||
import { BrowserDesktop } from "./rpc"
|
||||
|
||||
type Entry = {
|
||||
bindingID: string
|
||||
@@ -20,7 +14,7 @@ type Entry = {
|
||||
abort: AbortController
|
||||
registered: PromiseWithResolvers<void>
|
||||
requests: Map<string, { abort: AbortController; tabID?: Browser.TabID }>
|
||||
report?: (event: BrowserPaneEvent["event"]) => void
|
||||
report?: (event: BrowserDesktop.Event) => void
|
||||
cleanup?: () => void
|
||||
pages: Map<Browser.TabID, BrowserPage>
|
||||
focusedTabID: Browser.TabID | null
|
||||
@@ -29,15 +23,16 @@ type Entry = {
|
||||
network?: BrowserNetwork
|
||||
}
|
||||
|
||||
export function createBrowserPane() {
|
||||
export function createBrowserPane(ctx: MainPlugin.Context) {
|
||||
const entries = new Map<string, Entry>()
|
||||
// Keep long-lived RPC requests off Chromium's shared HTTP connection pool.
|
||||
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
|
||||
const runtime = ManagedRuntime.make(Layer.empty)
|
||||
let disposed = false
|
||||
return {
|
||||
async register(win: BrowserWindow, bindingID: string, target: BrowserPaneTarget) {
|
||||
if (disposed || !destinationOrigin(target.endpoint.url)) throw new Error("browser.pane.registration.invalid")
|
||||
if (target.endpoint.username && !target.endpoint.password) throw new Error("browser.pane.endpoint.invalid")
|
||||
async register(bindingID: string, target: { sessionID: string; serverID: string }, signal?: AbortSignal) {
|
||||
signal?.throwIfAborted()
|
||||
const win = ctx.window
|
||||
if (disposed) throw new Error("browser.pane.registration.invalid")
|
||||
if (entries.has(bindingID)) throw new Error("browser.pane.owner.invalid")
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) throw new Error("browser.pane.owner.unavailable")
|
||||
const sessionID = SessionID.make(target.sessionID)
|
||||
@@ -68,20 +63,7 @@ export function createBrowserPane() {
|
||||
void runtime
|
||||
.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
// The renderer never holds the managed sidecar's password; Node requests bypass
|
||||
// the webRequest header injection, so resolve the credential here in main.
|
||||
const authorization = target.endpoint.password
|
||||
? `Basic ${Buffer.from(`${target.endpoint.username ?? "opencode"}:${target.endpoint.password}`).toString("base64")}`
|
||||
: SidecarCredentials.authorization(SidecarCredentials.get(), target.endpoint.url)
|
||||
const client = yield* OpenCode.make({ baseUrl: target.endpoint.url }).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
authorization
|
||||
? HttpClient.mapRequest(http, HttpClientRequest.setHeader("authorization", authorization))
|
||||
: http,
|
||||
),
|
||||
)
|
||||
const client = yield* Effect.promise(() => ctx.client(target.serverID))
|
||||
const session = yield* client.session.get({ sessionID })
|
||||
const options = {
|
||||
location: { directory: session.location.directory, workspace: session.location.workspaceID },
|
||||
@@ -112,7 +94,7 @@ export function createBrowserPane() {
|
||||
// attachment ends; the results queued behind it then never name a tab the server lacks.
|
||||
// "unavailable" means the server already dropped this attachment, which attach reports.
|
||||
entry.report = (event) => {
|
||||
const local = Effect.sync(() => publish(entry, event))
|
||||
const local = Effect.promise(() => publish(entry, event))
|
||||
if (event.type !== "state") return send(local)
|
||||
send(
|
||||
rpc.state({ ...attachment, state: event.state ?? { tabs: [], focusedTabID: null } }, options).pipe(
|
||||
@@ -214,32 +196,20 @@ export function createBrowserPane() {
|
||||
)
|
||||
.catch(stop)
|
||||
const timeout = setTimeout(stop, 15_000)
|
||||
await entry.registered.promise.finally(() => clearTimeout(timeout))
|
||||
signal?.addEventListener("abort", stop, { once: true })
|
||||
await entry.registered.promise.finally(() => {
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener("abort", stop)
|
||||
})
|
||||
if (entries.get(bindingID) !== entry) throw new Error("browser.pane.registration.closed")
|
||||
publishState(entry)
|
||||
},
|
||||
layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) {
|
||||
const entry = owned(win, bindingID)
|
||||
if (!value) return entry.pages.forEach((page) => page.setVisible(false))
|
||||
const page = entry.pages.get(value.tabID)
|
||||
if (!page) return
|
||||
const bounds = value.bounds
|
||||
if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
page.setVisible(false)
|
||||
return
|
||||
}
|
||||
entry.pages.forEach((other) => {
|
||||
if (other !== page) other.setVisible(false)
|
||||
})
|
||||
page.layout(bounds, value.background, value.radius)
|
||||
page.setVisible(true)
|
||||
async command(bindingID: string, command: Browser.Action, signal = ctx.lifecycle.signal) {
|
||||
const entry = owned(bindingID)
|
||||
await execute(entry, { action: command, files: [] }, signal)
|
||||
},
|
||||
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
|
||||
const entry = owned(win, bindingID)
|
||||
await execute(entry, { action: command, files: [] }, new AbortController().signal)
|
||||
},
|
||||
async close(win: BrowserWindow, bindingID: string) {
|
||||
close(owned(win, bindingID))
|
||||
async close(bindingID: string) {
|
||||
close(owned(bindingID))
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true
|
||||
@@ -248,15 +218,15 @@ export function createBrowserPane() {
|
||||
},
|
||||
}
|
||||
|
||||
function owned(win: BrowserWindow, bindingID: string) {
|
||||
function owned(bindingID: string) {
|
||||
const entry = entries.get(bindingID)
|
||||
if (!entry || entry.win !== win) throw new Error("browser.pane.unavailable")
|
||||
if (!entry || entry.win !== ctx.window) throw new Error("browser.pane.unavailable")
|
||||
return entry
|
||||
}
|
||||
|
||||
function publish(entry: Entry, event: BrowserPaneEvent["event"]) {
|
||||
async function publish(entry: Entry, event: BrowserDesktop.Event) {
|
||||
if (!entries.has(entry.bindingID) || entry.win.isDestroyed() || entry.win.webContents.isDestroyed()) return
|
||||
emitIpcEvent(entry.win.webContents, new BrowserPaneEvent({ bindingID: entry.bindingID, event }))
|
||||
await ctx.emit(BrowserDesktop.Definition, "changed", { bindingID: entry.bindingID, event })
|
||||
}
|
||||
|
||||
function close(entry: Entry, reason = "browser.pane.registration.closed") {
|
||||
@@ -296,6 +266,7 @@ export function createBrowserPane() {
|
||||
const event = {
|
||||
type: "state" as const,
|
||||
state: { tabs: Array.from(entry.pages.values(), (page) => page.state()), focusedTabID: entry.focusedTabID },
|
||||
surfaces: Object.fromEntries(Array.from(entry.pages.values(), (page) => [page.state().id, page.surfaceID])),
|
||||
...(error === undefined ? {} : { error }),
|
||||
}
|
||||
const next = JSON.stringify(event)
|
||||
@@ -304,9 +275,9 @@ export function createBrowserPane() {
|
||||
report(entry, event)
|
||||
}
|
||||
|
||||
function report(entry: Entry, event: BrowserPaneEvent["event"]) {
|
||||
function report(entry: Entry, event: BrowserDesktop.Event) {
|
||||
if (entry.report) return entry.report(event)
|
||||
publish(entry, event)
|
||||
void publish(entry, event).catch(console.error)
|
||||
}
|
||||
|
||||
function create(entry: Entry, initialize = true, popupOptions?: Electron.BrowserWindowConstructorOptions) {
|
||||
@@ -317,6 +288,7 @@ export function createBrowserPane() {
|
||||
}
|
||||
const page = createBrowserPage(entry.win, {
|
||||
id,
|
||||
surfaces: ctx.surfaces,
|
||||
partition: entry.partition,
|
||||
network: entry.network,
|
||||
initialize,
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Plugin } from "@opencode/plugin/desktop"
|
||||
import { Panel, NativeSurface } from "@opencode/plugin/desktop/solid"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Stack, SettingsRow } from "@opencode/ui/layout"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createBrowser } from "./model"
|
||||
import { BrowserToolbar } from "./toolbar"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.browser",
|
||||
setup(ctx) {
|
||||
if (!ctx.app.native) return
|
||||
const [settings, setSettings] = ctx.storage.store("settings", { initial: { enabled: false } })
|
||||
const browser = createBrowser(ctx, () => settings.enabled)
|
||||
ctx.ui.slot({
|
||||
append: "settings.experimental",
|
||||
render: () => (
|
||||
<SettingsRow
|
||||
title={ctx.i18n.t("settings.general.row.browserPane.title")}
|
||||
description={ctx.i18n.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={(enabled) =>
|
||||
setSettings((settings) => {
|
||||
settings.enabled = enabled
|
||||
})
|
||||
}
|
||||
hideLabel
|
||||
>
|
||||
{ctx.i18n.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
),
|
||||
})
|
||||
ctx.ui.slot({
|
||||
append: "session.panel.actions",
|
||||
when: () => {
|
||||
const session = ctx.sessions.current()
|
||||
return !!session && browser.available(session)
|
||||
},
|
||||
render: ({ session }) => (
|
||||
<Menu.Item onSelect={() => browser.open(session)}>
|
||||
<Icon name="window-cursor" size="small" />
|
||||
{ctx.i18n.t("session.tab.browser")}
|
||||
</Menu.Item>
|
||||
),
|
||||
})
|
||||
ctx.ui.slot({
|
||||
append: "session.panel",
|
||||
when: () => settings.enabled,
|
||||
render: ({ session }) => (
|
||||
<For each={browser.tabs(session)}>
|
||||
{(tab) => (
|
||||
<Panel
|
||||
id={tab.id}
|
||||
title={tab.title || ctx.i18n.t("session.tab.browser")}
|
||||
icon={<Icon name="window-cursor" size="small" />}
|
||||
loading={tab.loading}
|
||||
onClose={() => browser.close(session, tab.id)}
|
||||
onSelect={() => browser.focus(session, tab.id)}
|
||||
>
|
||||
<Stack>
|
||||
<BrowserToolbar
|
||||
context={ctx}
|
||||
tab={tab}
|
||||
error={browser.state(session)?.error}
|
||||
command={(action) => browser.command(session, action)}
|
||||
/>
|
||||
<Show when={browser.state(session)?.surfaces[tab.id]}>{(id) => <NativeSurface id={id()} />}</Show>
|
||||
</Stack>
|
||||
</Panel>
|
||||
)}
|
||||
</For>
|
||||
),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import { BrowserDesktop } from "./rpc"
|
||||
import { createBrowserPane } from "./connection"
|
||||
|
||||
export default MainPlugin.define({
|
||||
id: "opencode.browser",
|
||||
rpc: BrowserDesktop.Definition,
|
||||
setup(ctx) {
|
||||
const browser = createBrowserPane(ctx)
|
||||
ctx.lifecycle.own(() => {
|
||||
void browser.dispose().catch(console.error)
|
||||
})
|
||||
return {
|
||||
async register(input, call) {
|
||||
await browser.register(input.bindingID, input, call.signal)
|
||||
return null
|
||||
},
|
||||
async command(input, call) {
|
||||
await browser.command(input.bindingID, input.action, call.signal)
|
||||
return null
|
||||
},
|
||||
async close(input) {
|
||||
await browser.close(input.bindingID)
|
||||
return null
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Context, SessionContext } from "@opencode/plugin/desktop"
|
||||
import { batch, createEffect, getOwner, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { BrowserDesktop } from "./rpc"
|
||||
|
||||
type State = { browser: Browser.State | null; surfaces: Readonly<Record<string, string>>; error?: string }
|
||||
type Live = {
|
||||
session: SessionContext
|
||||
bindingID: string
|
||||
attempts: number
|
||||
retry?: ReturnType<typeof setTimeout>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createBrowser(ctx: Context, enabled: () => boolean) {
|
||||
const rpc = ctx.main.rpc(BrowserDesktop.Definition)
|
||||
const [states, setState] = createStore<Record<string, State | undefined>>({})
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, boolean>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const owner = getOwner()
|
||||
const close = (key: string) => {
|
||||
live.get(key)?.dispose()
|
||||
live.delete(key)
|
||||
setState(key, undefined)
|
||||
}
|
||||
const available = (session: SessionContext) =>
|
||||
enabled() && session.server.compatible && !unsupported[session.server.id]
|
||||
const command = (session: SessionContext, action: Browser.Action) => {
|
||||
const entry = live.get(session.key)
|
||||
if (!entry) return
|
||||
void rpc.command({ bindingID: entry.bindingID, action }).catch(() => {
|
||||
if (live.get(session.key) === entry) setState(session.key, "error", ctx.i18n.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
ctx.lifecycle.own(
|
||||
rpc.events.on("changed", ({ bindingID, event }) => {
|
||||
const entry = Array.from(live.values()).find((entry) => entry.bindingID === bindingID)
|
||||
if (!entry) return
|
||||
const session = entry.session
|
||||
if (event.type === "focus") {
|
||||
ctx.ui.panel.open(event.tabID, session)
|
||||
return
|
||||
}
|
||||
if (event.error === "browser.pane.unsupported") {
|
||||
setUnsupported(session.server.id, true)
|
||||
close(session.key)
|
||||
return
|
||||
}
|
||||
if (event.error === "browser.pane.replaced") {
|
||||
entry.dispose()
|
||||
setState(session.key, { browser: null, surfaces: {}, error: ctx.i18n.t("session.browser.replaced") })
|
||||
return
|
||||
}
|
||||
batch(() => {
|
||||
setState(session.key, "browser", reconcile(event.state))
|
||||
setState(session.key, "surfaces", reconcile(event.surfaces))
|
||||
setState(session.key, "error", event.error ? ctx.i18n.t("common.requestFailed") : undefined)
|
||||
})
|
||||
if (event.state) entry.attempts = 0
|
||||
if (event.error === "browser.pane.registration.closed") {
|
||||
clearTimeout(entry.retry)
|
||||
entry.retry = setTimeout(() => register(entry), Math.min(30_000, 1_000 * 2 ** entry.attempts++))
|
||||
}
|
||||
}),
|
||||
)
|
||||
const register = (entry: Live) => {
|
||||
if (live.get(entry.session.key) !== entry) return
|
||||
// The host resolves the current endpoint and credentials on every attempt.
|
||||
void rpc
|
||||
.register({ bindingID: entry.bindingID, sessionID: entry.session.sessionID, serverID: entry.session.server.id })
|
||||
.catch(() => {})
|
||||
}
|
||||
createEffect(() => {
|
||||
const sessions = ctx.sessions.list()
|
||||
Array.from(live).forEach(([key, entry]) => {
|
||||
if (!sessions.some((session) => session.key === key) || !available(entry.session)) close(key)
|
||||
})
|
||||
sessions.filter(available).forEach((session) => {
|
||||
if (live.has(session.key)) return
|
||||
const entry: Live = { session, bindingID: crypto.randomUUID(), attempts: 0, dispose: () => {} }
|
||||
live.set(session.key, entry)
|
||||
setState(session.key, { browser: null, surfaces: {} })
|
||||
const stop = runWithOwner(owner, () =>
|
||||
session.server.data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === session.sessionID) register(entry)
|
||||
}),
|
||||
)
|
||||
entry.dispose = () => {
|
||||
stop?.()
|
||||
clearTimeout(entry.retry)
|
||||
void rpc.close({ bindingID: entry.bindingID }).catch(() => {})
|
||||
}
|
||||
if (!session.creating) register(entry)
|
||||
})
|
||||
})
|
||||
ctx.lifecycle.own(() => Array.from(live.keys()).forEach(close))
|
||||
return {
|
||||
available,
|
||||
state: (session: SessionContext) => states[session.key],
|
||||
tabs: (session: SessionContext) => states[session.key]?.browser?.tabs ?? [],
|
||||
command,
|
||||
open: (session: SessionContext) => command(session, { type: "tabs.open" }),
|
||||
close: (session: SessionContext, tabID: Browser.TabID) => command(session, { type: "tabs.close", tabID }),
|
||||
focus(session: SessionContext, tabID: Browser.TabID) {
|
||||
if (states[session.key]?.browser?.focusedTabID !== tabID) command(session, { type: "tabs.focus", tabID })
|
||||
},
|
||||
}
|
||||
}
|
||||
+12
-48
@@ -2,13 +2,13 @@ import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import electron, { type BrowserWindow, type WebContents } from "electron"
|
||||
import type { Protocol } from "devtools-protocol"
|
||||
import { Schema } from "effect"
|
||||
import { createCdp, abortError, waitFor } from "./browser/cdp"
|
||||
import { createBrowserFiles } from "./browser/files"
|
||||
import { createDiagnostics } from "./browser/diagnostics"
|
||||
import { createProfiling } from "./browser/profiling"
|
||||
import { createCornerImages } from "./browser/corners"
|
||||
import type { BrowserNetwork } from "./browser/network"
|
||||
import { destinationOrigin, normalizeURL } from "./browser/policy"
|
||||
import { createCdp, abortError, waitFor } from "./native/cdp"
|
||||
import { createBrowserFiles } from "./native/files"
|
||||
import { createDiagnostics } from "./native/diagnostics"
|
||||
import { createProfiling } from "./native/profiling"
|
||||
import type { BrowserNetwork } from "./native/network"
|
||||
import { destinationOrigin, normalizeURL } from "./native/policy"
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
|
||||
type Element = { backendID: number; frameID: string; sessionID?: string }
|
||||
let nextRef = 0
|
||||
@@ -39,6 +39,7 @@ export function createBrowserPage(
|
||||
popup: (options: Electron.BrowserWindowConstructorOptions) => WebContents
|
||||
initialize?: boolean
|
||||
popupOptions?: Electron.BrowserWindowConstructorOptions
|
||||
surfaces: MainPlugin.Context["surfaces"]
|
||||
},
|
||||
) {
|
||||
const view = new electron.WebContentsView({
|
||||
@@ -231,15 +232,7 @@ export function createBrowserPage(
|
||||
dialog = null
|
||||
publish()
|
||||
})
|
||||
view.setBounds({ x: 0, y: 0, width: 1000, height: 700 })
|
||||
view.setVisible(false)
|
||||
win.contentView.addChildView(view)
|
||||
const corners = [new electron.ImageView(), new electron.ImageView()]
|
||||
let cornerKey = ""
|
||||
corners.forEach((corner) => {
|
||||
corner.setVisible(false)
|
||||
win.contentView.addChildView(corner)
|
||||
})
|
||||
const surface = options.surfaces.register(view)
|
||||
const ready = Promise.all([
|
||||
files.ready,
|
||||
...(options.initialize === false ? [] : [contents.loadURL("about:blank")]),
|
||||
@@ -256,36 +249,10 @@ export function createBrowserPage(
|
||||
|
||||
return {
|
||||
view,
|
||||
surfaceID: surface.id,
|
||||
contents,
|
||||
state,
|
||||
ready,
|
||||
layout(bounds: Electron.Rectangle, background?: readonly [number, number, number, number], radius = 10) {
|
||||
view.setBounds(bounds)
|
||||
const size = Math.min(radius, Math.floor(bounds.width / 2), Math.floor(bounds.height / 2))
|
||||
const scale = electron.screen.getDisplayMatching(win.getBounds()).scaleFactor
|
||||
const key = background && size > 0 ? `${background}:${size}:${scale}` : ""
|
||||
if (key && key !== cornerKey && background) {
|
||||
createCornerImages(background, size, scale).forEach((image, index) => corners[index].setImage(image))
|
||||
}
|
||||
cornerKey = key
|
||||
corners.forEach((corner, index) => {
|
||||
// A composited layer is required above WebContentsView. A zero-duration
|
||||
// bounds update creates that layer without a visible animation.
|
||||
corner.setBounds(
|
||||
{
|
||||
x: bounds.x + (index ? bounds.width - size : 0),
|
||||
y: bounds.y + bounds.height - size,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
{ animate: { duration: 0 } },
|
||||
)
|
||||
})
|
||||
},
|
||||
setVisible(visible: boolean) {
|
||||
view.setVisible(visible)
|
||||
corners.forEach((corner) => corner.setVisible(visible && !!cornerKey))
|
||||
},
|
||||
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
|
||||
await ready
|
||||
abortError(signal)
|
||||
@@ -354,10 +321,7 @@ export function createBrowserPage(
|
||||
await profiling.dispose()
|
||||
cdp.dispose()
|
||||
refs.clear()
|
||||
if (!win.isDestroyed()) {
|
||||
corners.forEach((corner) => win.contentView.removeChildView(corner))
|
||||
win.contentView.removeChildView(view)
|
||||
}
|
||||
surface.dispose()
|
||||
if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false })
|
||||
await files.dispose()
|
||||
},
|
||||
@@ -671,7 +635,7 @@ export function createBrowserPage(
|
||||
case "heap.compare":
|
||||
return result({ tab: state(), ...(await profiling.analyze(action)) })
|
||||
case "lighthouse": {
|
||||
const { audit } = await import("./browser/lighthouse")
|
||||
const { audit } = await import("./native/lighthouse")
|
||||
const report = await audit(contents, files, cdp, captureSources)
|
||||
return result(
|
||||
{ tab: state(), scores: report.scores, failures: report.failures },
|
||||
@@ -0,0 +1,27 @@
|
||||
export * as BrowserDesktop from "./rpc.js"
|
||||
import { Rpc } from "@opencode/schema/rpc"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Event = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("state"),
|
||||
state: Schema.NullOr(Browser.State),
|
||||
surfaces: Schema.Record(Schema.String, Schema.String),
|
||||
error: Schema.optionalKey(Schema.String),
|
||||
}),
|
||||
])
|
||||
export type Event = typeof Event.Type
|
||||
export const Definition = Rpc.define({
|
||||
id: "browser.desktop",
|
||||
methods: {
|
||||
register: {
|
||||
input: Schema.Struct({ bindingID: Schema.String, sessionID: Schema.String, serverID: Schema.String }),
|
||||
output: Schema.Null,
|
||||
},
|
||||
command: { input: Schema.Struct({ bindingID: Schema.String, action: Browser.Action }), output: Schema.Null },
|
||||
close: { input: Schema.Struct({ bindingID: Schema.String }), output: Schema.Null },
|
||||
},
|
||||
events: { changed: { schema: Schema.Struct({ bindingID: Schema.String, event: Event }) } },
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Context } from "@opencode/plugin/desktop"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { InlineForm, Text, Toolbar } from "@opencode/ui/layout"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function BrowserToolbar(props: {
|
||||
context: Context
|
||||
tab: Browser.Tab
|
||||
error?: string
|
||||
command(action: Browser.Action): void
|
||||
}) {
|
||||
const [state, setState] = createStore({ address: "", editing: false })
|
||||
createEffect(() => {
|
||||
if (!state.editing) setState("address", props.tab.url)
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<Toolbar>
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={!props.tab[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={props.context.i18n.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => props.command({ type: direction, tabID: props.tab.id })}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="large"
|
||||
aria-label={props.context.i18n.t(props.tab.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => props.command({ type: props.tab.loading ? "stop" : "reload", tabID: props.tab.id })}
|
||||
icon={
|
||||
<Show when={props.tab.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<InlineForm
|
||||
onSubmit={() => {
|
||||
if (state.address.trim()) props.command({ type: "navigate", tabID: props.tab.id, url: state.address })
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
value={state.address}
|
||||
placeholder={props.context.i18n.t("session.browser.address.placeholder")}
|
||||
aria-label={props.context.i18n.t("session.browser.address")}
|
||||
onFocus={() => setState("editing", true)}
|
||||
onBlur={() => setState({ editing: false, address: props.tab.url })}
|
||||
onInput={(event) => setState("address", event.currentTarget.value)}
|
||||
/>
|
||||
</InlineForm>
|
||||
</Toolbar>
|
||||
<Show when={props.error}>
|
||||
<Text tone="error">{props.error}</Text>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { analyzeCpu, analyzeTrace, parseHeap } from "../src/main/browser/analysis"
|
||||
import { analyzeCpu, analyzeTrace, parseHeap } from "../src/native/analysis"
|
||||
|
||||
test("trace analysis reports observed durations without inventing Web Vitals", () => {
|
||||
const result = analyzeTrace({
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { BrowserDesktop } from "../src/rpc"
|
||||
import { Schema } from "effect"
|
||||
|
||||
test("the browser companion has no imports from host application or server internals", async () => {
|
||||
const root = path.join(import.meta.dir, "../src")
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.{ts,tsx}").scan(root))
|
||||
const imports = await Promise.all(
|
||||
files.map(async (file) => ({ file, source: await Bun.file(path.join(root, file)).text() })),
|
||||
)
|
||||
expect(
|
||||
imports
|
||||
.filter(({ source }) => /["']@opencode\/(?:app|desktop|core|server)(?:[\/"'])/.test(source))
|
||||
.map(({ file }) => file),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("the public local contract loads without Electron and validates its inventory", () => {
|
||||
expect(BrowserDesktop.Definition.id).toBe("browser.desktop")
|
||||
const decode = Schema.decodeUnknownSync(BrowserDesktop.Event)
|
||||
expect(decode({ type: "state", state: { tabs: [], focusedTabID: null }, surfaces: {} }).type).toBe("state")
|
||||
expect(() => decode({ type: "focus", tabID: "wrong-owner" })).toThrow()
|
||||
})
|
||||
+3
-3
@@ -2,9 +2,9 @@ import { expect, test } from "bun:test"
|
||||
import { rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { browserFailure, protocolError } from "../src/main/browser/errors"
|
||||
import { createBrowserFiles } from "../src/main/browser/files"
|
||||
import { analyzeCpu, analyzeTrace, parseHeap } from "../src/main/browser/analysis"
|
||||
import { browserFailure, protocolError } from "../src/native/errors"
|
||||
import { createBrowserFiles } from "../src/native/files"
|
||||
import { analyzeCpu, analyzeTrace, parseHeap } from "../src/native/analysis"
|
||||
|
||||
const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { destinationOrigin } from "./browser/policy"
|
||||
import { destinationOrigin } from "../src/native/policy"
|
||||
|
||||
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
|
||||
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["bun", "node", "electron"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -41,6 +41,10 @@ Run `bun test` and `bun typecheck` from this package for its contract checks.
|
||||
Native browser coverage lives with the desktop implementation
|
||||
(`packages/desktop/test/browser-native.test.ts`), not in this package.
|
||||
|
||||
The exploratory Desktop companion is `@opencode/plugin-browser-desktop`. It
|
||||
uses this package's public RPC/proxy exports and the generic Desktop extension
|
||||
host; the server plugin does not load its renderer or Electron implementation.
|
||||
|
||||
## RPC
|
||||
|
||||
The plugin-owned contract is `@opencode/plugin-browser/rpc`. This entrypoint
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Desktop extensions — exploratory API
|
||||
|
||||
This draft adds a renderer entrypoint at `@opencode/plugin/desktop` and a trusted main entrypoint at `@opencode/plugin/desktop/main`. Built-in/static registrations run through the same contracts external packages can import. Installing and resolving arbitrary third-party renderer packages is a follow-up; this draft does not add a second package manager or a sandbox.
|
||||
|
||||
## Contributions
|
||||
|
||||
```tsx
|
||||
import { Plugin } from "@opencode/plugin/desktop"
|
||||
import { Panel } from "@opencode/plugin/desktop/solid"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "example.inspector",
|
||||
setup(ctx) {
|
||||
ctx.ui.slot({
|
||||
append: "session.panel",
|
||||
when: () => available(),
|
||||
render: ({ session }) => (
|
||||
<Panel id="inspector" title={title()} onClose={close}>
|
||||
<Inspector session={session} />
|
||||
</Panel>
|
||||
),
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Slots share the TUI resolver and its `append`, `prepend`, `before`, `after`, and `replace` rules. The plugin controls `when`, commands, and opening. Panel IDs are scoped to the plugin. Multiple reactive `Panel` instances share the host's ordered, closable tab strip. Closing, hiding, and disposing content are separate operations. Panel declarations live with the session route, even when the panel is closed.
|
||||
|
||||
Use the host's UI components directly. `@opencode/ui/layout` supplies shared layout, toolbar, form, text, and settings-row components. The browser companion in the next layer uses these components rather than shipping private CSS. Solid and TanStack remain normal libraries; the SDK adds no query framework.
|
||||
|
||||
## Lifetimes and data
|
||||
|
||||
- `ctx.sessions.list()` contains sessions visited by each open shell tab, retaining child-session ownership across navigation. `current()` is the currently routed session.
|
||||
- Every session carries stable server, shell-tab, and session identities. Its server provides the existing client and reactive data APIs.
|
||||
- `ctx.lifecycle.own` owns custom cleanup; its signal aborts on unload. Slots, commands and main RPC subscriptions are owned automatically.
|
||||
- `storage.store` uses the host's persistence and cross-window synchronization. `storage.memory` retains window-local values across extension reloads. Schema-specific migrations remain an open API design item.
|
||||
- `commands.register` accepts a reactive command list and registers with the existing command palette, keyboard, and slash-command host. IDs are plugin-scoped.
|
||||
- `i18n` resolves existing host keys through the active language. Extension-owned translation catalogs are a follow-up.
|
||||
|
||||
## Local main entrypoint
|
||||
|
||||
`MainPlugin.define({ id, rpc, setup })` uses a public `Rpc.define` contract. Inputs, outputs and events are decoded/encoded at the bridge. Effect codecs can carry bytes over the JSON envelope, and Standard Schema/JSON Schema are supported. Methods receive a cancellation signal. Null is the void wire value.
|
||||
|
||||
Main context exposes the owning Electron window, a lifecycle, authenticated Node-side OpenCode clients for host-known server IDs, and a native surface registrar. It does not import Core. A renderer calls `ctx.main.rpc(contract)` and subscribes to its events.
|
||||
|
||||
`ctx.surfaces.register(view)` returns an opaque, window/extension-owned ID. `<NativeSurface id={id} />` presents that view. The host owns bounds, zoom conversion, corner composition, and menu/dialog occlusion. The extension owns the view's domain behavior and disposal.
|
||||
|
||||
## Verification
|
||||
|
||||
The pre-change production benchmark is `desktop-extensions-before`, at base `c3f1bdaf97`. Two samples per scenario completed: cold/closed first-correct median 223.60 ms, cold/open 255.75 ms, warm/closed 57.70 ms, warm/open 95.55 ms, warm/resized 92.85 ms. These are exploratory measurements, not machine-independent thresholds.
|
||||
|
||||
Focused tests cover lifecycle teardown, codec validation and binary round trips, shared slot ordering, and actual application panel behavior through an independent fixture plugin. The dependent browser extraction exercises native surfaces and server RPC.
|
||||
@@ -14,6 +14,8 @@
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./host": "./src/host.ts",
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./desktop": "./src/desktop/index.ts",
|
||||
"./desktop/solid": "./src/desktop/solid.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
@@ -66,6 +68,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { OpenCodeClient, LocationRef } from "@opencode/client"
|
||||
import type { Data } from "../tui/context.js"
|
||||
import type { Accessor, JSX } from "solid-js"
|
||||
import type { Store } from "solid-js/store"
|
||||
import type { Rpc } from "@opencode/schema/rpc"
|
||||
import type { RpcClient } from "./rpc.js"
|
||||
|
||||
export type Dispose = () => void
|
||||
export interface Lifecycle {
|
||||
readonly signal: AbortSignal
|
||||
own(dispose: Dispose): Dispose
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
readonly id: string
|
||||
readonly client: OpenCodeClient
|
||||
readonly data: Data
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
/** A visited session remains owned by its shell tab, including while another route is shown. */
|
||||
export interface SessionContext {
|
||||
readonly key: string
|
||||
readonly ownerID: string
|
||||
readonly sessionID: string
|
||||
readonly server: Server
|
||||
readonly creating: boolean
|
||||
readonly location: LocationRef | undefined
|
||||
}
|
||||
|
||||
export interface PanelInput {
|
||||
readonly session: SessionContext
|
||||
}
|
||||
|
||||
export interface SlotMap {
|
||||
readonly app: Readonly<Record<string, never>>
|
||||
readonly "titlebar.actions": Readonly<Record<string, never>>
|
||||
readonly "settings.experimental": Readonly<Record<string, never>>
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "session.panel.actions": PanelInput
|
||||
readonly "session.composer.top": PanelInput
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
type Placement<Path extends string> = {
|
||||
[Kind in "append" | "prepend" | "before" | "after" | "replace"]: { readonly [Key in Kind]: Path } & {
|
||||
readonly [Key in Exclude<"append" | "prepend" | "before" | "after" | "replace", Kind>]?: never
|
||||
}
|
||||
}["append" | "prepend" | "before" | "after" | "replace"]
|
||||
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
|
||||
? Placement<Path> & { readonly when?: Accessor<boolean>; readonly render: (input: SlotMap[Path]) => JSX.Element }
|
||||
: never
|
||||
|
||||
export interface Command {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly group?: string
|
||||
readonly bind?: string
|
||||
readonly slash?: string
|
||||
readonly enabled?: boolean
|
||||
readonly palette?: boolean
|
||||
readonly run: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface Storage {
|
||||
store<Value extends object>(
|
||||
key: string,
|
||||
options: { initial: Value },
|
||||
): readonly [Store<Value>, (update: (draft: Value) => void) => void]
|
||||
memory<Value extends object>(
|
||||
key: string,
|
||||
options: { initial: Value },
|
||||
): readonly [Store<Value>, (update: (draft: Value) => void) => void]
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
readonly app: { readonly version?: string; readonly windowID?: string; readonly native: boolean }
|
||||
readonly lifecycle: Lifecycle
|
||||
readonly sessions: { list(): readonly SessionContext[]; current(): SessionContext | undefined }
|
||||
readonly storage: Storage
|
||||
readonly commands: { register(commands: Accessor<readonly Command[]>): Dispose; dispatch(id: string): void }
|
||||
readonly main: { rpc<D extends Rpc.Definition>(definition: D): RpcClient<D> }
|
||||
readonly ui: {
|
||||
slot(claim: SlotClaim): Dispose
|
||||
readonly panel: {
|
||||
open(id: string, session: SessionContext): boolean
|
||||
close(id: string, session: SessionContext): boolean
|
||||
selected(id: string, session: SessionContext): boolean
|
||||
}
|
||||
}
|
||||
/** Host copy uses the host language; extension-specific copy can be supplied as a fallback. */
|
||||
readonly i18n: { locale(): string; t(key: string, params?: Record<string, string | number>): string }
|
||||
}
|
||||
|
||||
export interface PanelProps {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly icon?: JSX.Element
|
||||
readonly badge?: string | number
|
||||
readonly loading?: boolean
|
||||
readonly onClose?: () => void
|
||||
readonly onSelect?: () => void
|
||||
readonly children: JSX.Element
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { usePlugin } from "./solid.js"
|
||||
export type { Context, SessionContext, Server, SlotClaim, SlotMap, PanelProps, Lifecycle } from "./context.js"
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Lifecycle } from "./context.js"
|
||||
|
||||
export function createLifecycle(): Lifecycle & { dispose(): void } {
|
||||
const controller = new AbortController()
|
||||
const owned = new Set<() => void>()
|
||||
return {
|
||||
signal: controller.signal,
|
||||
own(dispose) {
|
||||
if (controller.signal.aborted) {
|
||||
dispose()
|
||||
return () => {}
|
||||
}
|
||||
const cleanup = () => {
|
||||
if (owned.delete(cleanup)) dispose()
|
||||
}
|
||||
owned.add(cleanup)
|
||||
return cleanup
|
||||
},
|
||||
dispose() {
|
||||
if (controller.signal.aborted) return
|
||||
controller.abort()
|
||||
const failures: unknown[] = []
|
||||
Array.from(owned)
|
||||
.reverse()
|
||||
.forEach((dispose) => {
|
||||
try {
|
||||
dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
})
|
||||
if (failures.length) throw new AggregateError(failures, "Extension cleanup failed")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export * as MainPlugin from "./main.js"
|
||||
import type { BrowserWindow, View } from "electron"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import type { Rpc } from "@opencode/schema/rpc"
|
||||
import type { Lifecycle } from "./context.js"
|
||||
|
||||
export interface Context {
|
||||
readonly window: BrowserWindow
|
||||
readonly lifecycle: Lifecycle
|
||||
/** Returns an authenticated Node-side client for a host-known server. */
|
||||
client(serverID: string): Promise<OpenCodeClient>
|
||||
readonly surfaces: {
|
||||
register(view: View): { readonly id: string; dispose(): void }
|
||||
}
|
||||
emit<D extends Rpc.Definition, Name extends keyof D["events"] & string>(
|
||||
definition: D,
|
||||
name: Name,
|
||||
data: Rpc.EventInputData<D["events"][Name]["schema"]>,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
export type Handlers<D extends Rpc.Definition> = {
|
||||
[Name in keyof D["methods"]]: (
|
||||
input: Rpc.Output<D["methods"][Name]["input"]>,
|
||||
call: { signal: AbortSignal; error: Rpc.ErrorFactory<D["methods"][Name]> },
|
||||
) => Rpc.HandlerOutput<D["methods"][Name]["output"]> | Promise<Rpc.HandlerOutput<D["methods"][Name]["output"]>>
|
||||
}
|
||||
|
||||
export interface Definition<D extends Rpc.Definition = Rpc.Definition> {
|
||||
readonly id: string
|
||||
readonly rpc: D
|
||||
readonly setup: (context: Context) => Handlers<D>
|
||||
}
|
||||
|
||||
/** Type-erased host entrypoint; authors register through define to retain method correlations. */
|
||||
export interface Entry {
|
||||
readonly id: string
|
||||
readonly rpc: Rpc.Definition
|
||||
readonly setup: (
|
||||
context: Context,
|
||||
) => Record<
|
||||
string,
|
||||
(
|
||||
input: unknown,
|
||||
call: { signal: AbortSignal; error: (type: string, message: string, data?: unknown) => never },
|
||||
) => unknown
|
||||
>
|
||||
}
|
||||
|
||||
export function define<const D extends Rpc.Definition>(definition: Definition<D>): Entry {
|
||||
return definition as Entry
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Context, Dispose } from "./context.js"
|
||||
|
||||
export interface Definition {
|
||||
readonly id: string
|
||||
readonly setup: (context: Context) => void | Dispose
|
||||
}
|
||||
|
||||
export function define<const T extends Definition>(plugin: T) {
|
||||
return plugin
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export * as DesktopExtension from "./protocol.js"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const id = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256))
|
||||
export const Endpoint = Schema.Struct({
|
||||
id,
|
||||
url: Schema.String,
|
||||
username: Schema.optionalKey(Schema.String),
|
||||
password: Schema.optionalKey(Schema.String),
|
||||
})
|
||||
export type Endpoint = typeof Endpoint.Type
|
||||
export const Layout = Schema.Struct({
|
||||
visible: Schema.Boolean,
|
||||
bounds: Schema.optionalKey(
|
||||
Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }),
|
||||
),
|
||||
background: Schema.optionalKey(Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])),
|
||||
radius: Schema.optionalKey(Schema.Number),
|
||||
})
|
||||
export type Layout = typeof Layout.Type
|
||||
export const Call = Schema.Struct({ extensionID: id, rpcID: id, method: id, requestID: id, input: Schema.Json })
|
||||
export type Call = typeof Call.Type
|
||||
export const Event = Schema.Struct({ extensionID: id, rpcID: id, name: id, data: Schema.Json })
|
||||
export type Event = typeof Event.Type
|
||||
|
||||
export interface Transport {
|
||||
call(input: Call, signal?: AbortSignal): Promise<unknown>
|
||||
onEvent(listener: (event: Event) => void): () => void
|
||||
surface(extensionID: string, surfaceID: string, layout?: Layout): void
|
||||
configure(servers: readonly Endpoint[]): void
|
||||
release(extensionID: string): void
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Effect, JsonSchema, Schema, SchemaRepresentation } from "effect"
|
||||
import type { Rpc } from "@opencode/schema/rpc"
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import type { DesktopExtension } from "./protocol.js"
|
||||
|
||||
type Input<S extends Rpc.Method["input"]> = S extends Schema.Top ? S["Type"] : Rpc.Input<S>
|
||||
export type RpcClient<D extends Rpc.Definition> = {
|
||||
readonly [Name in keyof D["methods"]]: (
|
||||
input: Input<D["methods"][Name]["input"]>,
|
||||
options?: { signal?: AbortSignal },
|
||||
) => Promise<Rpc.Output<D["methods"][Name]["output"]>>
|
||||
} & {
|
||||
readonly events: {
|
||||
on<Name extends keyof D["events"] & string>(
|
||||
name: Name,
|
||||
listener: (data: Rpc.EventData<D["events"][Name]["schema"]>) => void,
|
||||
): () => void
|
||||
}
|
||||
}
|
||||
|
||||
export class CallError extends Error {
|
||||
constructor(
|
||||
readonly type: string,
|
||||
message: string,
|
||||
readonly data?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
const codecs = new WeakMap<object, Schema.Codec<unknown>>()
|
||||
export async function decode(schema: Rpc.Method["input"], input: unknown): Promise<unknown> {
|
||||
if (Schema.isSchema(schema)) return Effect.runPromise(Schema.decodeUnknownEffect(schema)(input))
|
||||
if ("~standard" in schema) {
|
||||
const result = await (schema as StandardSchemaV1)["~standard"].validate(input)
|
||||
if (result.issues) throw new Error(result.issues.map((issue) => issue.message).join("; "))
|
||||
return result.value
|
||||
}
|
||||
const codec =
|
||||
codecs.get(schema) ??
|
||||
Schema.make<Schema.Codec<unknown>>(
|
||||
SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)).ast,
|
||||
)
|
||||
codecs.set(schema, codec)
|
||||
return Effect.runPromise(Schema.decodeUnknownEffect(codec)(input))
|
||||
}
|
||||
|
||||
export async function encode(schema: Rpc.Method["output"], value: unknown) {
|
||||
const encoded = Schema.isSchema(schema)
|
||||
? await Effect.runPromise(Schema.encodeUnknownEffect(schema)(value))
|
||||
: await decode(schema, value)
|
||||
return Schema.decodeUnknownSync(Schema.Json)(encoded)
|
||||
}
|
||||
|
||||
const Outcome = Schema.Union([
|
||||
Schema.Struct({ ok: Schema.Literal(true), output: Schema.Json }),
|
||||
Schema.Struct({
|
||||
ok: Schema.Literal(false),
|
||||
error: Schema.Struct({ type: Schema.String, message: Schema.String, data: Schema.optionalKey(Schema.Json) }),
|
||||
}),
|
||||
])
|
||||
|
||||
export function client<D extends Rpc.Definition>(
|
||||
extensionID: string,
|
||||
definition: D,
|
||||
transport: DesktopExtension.Transport,
|
||||
signal: AbortSignal,
|
||||
own: (dispose: () => void) => unknown,
|
||||
): RpcClient<D> {
|
||||
const methods = Object.fromEntries(
|
||||
Object.entries(definition.methods).map(([name, method]) => [
|
||||
name,
|
||||
async (input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
const result = Schema.decodeUnknownSync(Outcome)(
|
||||
await transport.call(
|
||||
{
|
||||
extensionID,
|
||||
rpcID: definition.id,
|
||||
method: name,
|
||||
requestID: crypto.randomUUID(),
|
||||
input: await encode(method.input, input),
|
||||
},
|
||||
options?.signal ? AbortSignal.any([signal, options.signal]) : signal,
|
||||
),
|
||||
)
|
||||
if (!result.ok) throw new CallError(result.error.type, result.error.message, result.error.data)
|
||||
return Schema.isSchema(method.output) ? decode(method.output, result.output) : result.output
|
||||
},
|
||||
]),
|
||||
)
|
||||
// The definition supplies every method name and the codecs preserve its input/output correlation.
|
||||
return Object.assign(methods, {
|
||||
events: {
|
||||
on(name: keyof D["events"] & string, listener: (data: unknown) => void) {
|
||||
const event = definition.events[name]
|
||||
if (!event) throw new Error(`Unknown extension event: ${name}`)
|
||||
let live = true
|
||||
const stop = transport.onEvent((message) => {
|
||||
if (
|
||||
!live ||
|
||||
signal.aborted ||
|
||||
message.extensionID !== extensionID ||
|
||||
message.rpcID !== definition.id ||
|
||||
message.name !== name
|
||||
)
|
||||
return
|
||||
void decode(event.schema, message.data)
|
||||
.then((data) => {
|
||||
if (live && !signal.aborted) listener(data)
|
||||
})
|
||||
.catch(console.error)
|
||||
})
|
||||
const dispose = () => {
|
||||
live = false
|
||||
stop()
|
||||
}
|
||||
own(dispose)
|
||||
return dispose
|
||||
},
|
||||
},
|
||||
}) as RpcClient<D>
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createComponent, createContext, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import type { Context, PanelProps, SessionContext } from "./context.js"
|
||||
|
||||
const PluginContext = createContext<Context>()
|
||||
const SurfaceContext = createContext<(props: { id: string }) => JSX.Element>()
|
||||
const PanelContext = createContext<{
|
||||
session: SessionContext
|
||||
register(props: PanelProps): void
|
||||
}>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ value: Context }>) {
|
||||
return createComponent(PluginContext.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function PanelProvider(props: ParentProps<{ value: NonNullable<ReturnType<typeof usePanel>> }>) {
|
||||
return createComponent(PanelContext.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function usePanel() {
|
||||
return useContext(PanelContext)
|
||||
}
|
||||
|
||||
export function usePlugin() {
|
||||
const value = useContext(PluginContext)
|
||||
if (!value) throw new Error("Desktop plugin context is unavailable")
|
||||
return value
|
||||
}
|
||||
|
||||
export function NativeSurfaceProvider(props: ParentProps<{ render: (props: { id: string }) => JSX.Element }>) {
|
||||
return createComponent(SurfaceContext.Provider, {
|
||||
value: props.render,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function NativeSurface(props: { id: string }) {
|
||||
const render = useContext(SurfaceContext)
|
||||
if (!render) throw new Error("Native surfaces require a desktop extension host")
|
||||
return render(props)
|
||||
}
|
||||
|
||||
/** Declare a host-owned panel instance in the session.panel slot. */
|
||||
export function Panel(props: PanelProps): JSX.Element {
|
||||
const context = usePanel()
|
||||
if (!context) throw new Error("Panel must be contributed to session.panel")
|
||||
context.register(props)
|
||||
return null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Pure resolution of the slot tree: the mounted slot paths plus plugin claims
|
||||
// Shared resolution of the slot tree: the mounted slot paths plus plugin claims
|
||||
// in, per-path placement buckets plus diagnostics out. No solid, no I/O —
|
||||
// every policy rule (replacement takeover, hierarchy-beats-timeline,
|
||||
// last-enabled-wins, missing-target degradation) is testable as a data
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { z } from "zod"
|
||||
import { createLifecycle } from "../src/desktop/lifecycle"
|
||||
import { decode, encode } from "../src/desktop/rpc"
|
||||
import { resolveSlots } from "../src/slots"
|
||||
|
||||
test("extension teardown cancels work and disposes every resource in reverse order", () => {
|
||||
const scope = createLifecycle()
|
||||
const called: string[] = []
|
||||
scope.signal.addEventListener("abort", () => called.push("abort"))
|
||||
scope.own(() => called.push("first"))
|
||||
scope.own(() => {
|
||||
called.push("second")
|
||||
throw new Error("cleanup")
|
||||
})
|
||||
expect(() => scope.dispose()).toThrow(AggregateError)
|
||||
expect(called).toEqual(["abort", "second", "first"])
|
||||
scope.dispose()
|
||||
scope.own(() => called.push("late"))
|
||||
expect(called).toEqual(["abort", "second", "first", "late"])
|
||||
})
|
||||
|
||||
test("main RPC codecs transfer bytes and reject incompatible values", async () => {
|
||||
const schema = Schema.Struct({ bytes: Schema.Uint8ArrayFromBase64 })
|
||||
const input = { bytes: new Uint8Array([0, 1, 127, 255]) }
|
||||
expect(await decode(schema, await encode(schema, input))).toEqual(input)
|
||||
await expect(decode(schema, { bytes: 17 })).rejects.toThrow()
|
||||
await expect(encode(schema, { bytes: "wrong" })).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("main RPC accepts Standard Schema and JSON Schema contracts", async () => {
|
||||
expect(await decode(z.object({ name: z.string().min(1) }), { name: "inspector" })).toEqual({ name: "inspector" })
|
||||
await expect(decode({ type: "integer", minimum: 1 }, 0)).rejects.toThrow()
|
||||
expect(await decode({ type: "integer", minimum: 1 }, 2)).toBe(2)
|
||||
})
|
||||
|
||||
test("shared TUI/Desktop slots compose replacements and preserve neighboring contributions", () => {
|
||||
const result = resolveSlots({
|
||||
paths: new Set(["app", "app.panel"]),
|
||||
claims: [
|
||||
{ key: "a", plugin: "one", placement: { kind: "append", target: "app.panel" }, render: "A" },
|
||||
{ key: "b", plugin: "two", placement: { kind: "replace", target: "app.panel" }, render: "B" },
|
||||
{ key: "c", plugin: "three", placement: { kind: "after", target: "app.panel" }, render: "C" },
|
||||
],
|
||||
})
|
||||
expect(result.slotted.get("app.panel")?.replace?.render).toBe("B")
|
||||
expect(result.slotted.get("app.panel")?.after.map((claim) => claim.render)).toEqual(["C"])
|
||||
expect(result.suppressed.map((item) => item.claim.key)).toEqual(["a"])
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PluginContextProvider } from "@opencode/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode/plugin/tui/context"
|
||||
import type { Placement, PlacementKind } from "./structure"
|
||||
import type { Placement, PlacementKind } from "@opencode/plugin/slots"
|
||||
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClient } from "../context/client"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { stat } from "fs/promises"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Page } from "@opencode/plugin/tui/context"
|
||||
import { Host } from "@opencode/plugin/host"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { resolveSlots, type Claim } from "@opencode/plugin/slots"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { isShallowEqual } from "remeda"
|
||||
import type { SlotMap, SlotPath } from "@opencode/plugin/tui/context"
|
||||
import type { SlotRender } from "./api"
|
||||
import { contains, emptySlotted, type Claim } from "./structure"
|
||||
import { contains, emptySlotted, type Claim } from "@opencode/plugin/slots"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"./dialog": "./src/overlays/dialog/dialog.tsx",
|
||||
"./diff-changes": "./src/data-display/diff-changes/diff-changes.tsx",
|
||||
"./divider": "./src/layout/divider/divider.tsx",
|
||||
"./layout": "./src/layout/extension-layout.tsx",
|
||||
"./field": "./src/forms/field/field.tsx",
|
||||
"./file-tree.css": "./src/styles/file-tree.css",
|
||||
"./icon": "./src/icons/icon/icon.tsx",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { JSX, ParentProps } from "solid-js"
|
||||
|
||||
/** Shared layout primitives for composed extension UI. Visual choices remain semantic. */
|
||||
export function Stack(
|
||||
props: ParentProps<{ gap?: "none" | "small" | "medium"; padding?: "none" | "small" | "medium" }>,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
data-component="stack"
|
||||
class="min-w-0 min-h-0 h-full flex flex-col bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"gap-2": props.gap === "small",
|
||||
"gap-4": props.gap === "medium",
|
||||
"p-2": props.padding === "small",
|
||||
"p-4": props.padding === "medium",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toolbar(props: ParentProps) {
|
||||
return (
|
||||
<div
|
||||
data-component="toolbar"
|
||||
class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02"
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineForm(props: ParentProps<{ onSubmit: () => void }>) {
|
||||
return (
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
props.onSubmit()
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function Text(props: ParentProps<{ tone?: "default" | "muted" | "error" }>) {
|
||||
return (
|
||||
<span
|
||||
data-component="text"
|
||||
class="text-12-regular"
|
||||
classList={{
|
||||
"text-v2-text-text-base": !props.tone || props.tone === "default",
|
||||
"text-v2-text-text-muted": props.tone === "muted",
|
||||
"text-text-danger-base": props.tone === "error",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsRow(props: {
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<div data-component="settings-row">
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title">{props.title}</div>
|
||||
<div data-slot="settings-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user