mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 18:06:25 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b16d40f0 | ||
|
|
de7ab3b082 | ||
|
|
29a05fa343 | ||
|
|
0dc7aa2411 | ||
|
|
b5222ef904 | ||
|
|
30c25008ac | ||
|
|
7466567c51 | ||
|
|
39310ee609 | ||
|
|
cc40f5844c |
@@ -63,6 +63,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
@@ -426,6 +427,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
@@ -597,6 +599,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
|
||||
@@ -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,6 +64,7 @@
|
||||
"@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:*",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import type { RegisteredPanel } from "./provider"
|
||||
|
||||
/** Grouped content stays mounted while any declaration in its group is available. */
|
||||
export function ExtensionPanelContent(props: { panels: readonly RegisteredPanel[]; active: string | undefined }) {
|
||||
const selected = createMemo(() => props.panels.find((panel) => panel.key === props.active))
|
||||
const groups = createMemo(() => Array.from(new Set(props.panels.filter((panel) => panel.props.group).map(groupKey))))
|
||||
const single = createMemo(() => {
|
||||
const panel = selected()
|
||||
return panel && !panel.props.group ? panel : undefined
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<For each={groups()}>
|
||||
{(key) => {
|
||||
const [mounted, setMounted] = createSignal(false)
|
||||
const active = () => !!selected() && groupKey(selected()!) === key
|
||||
const declaration = props.panels.find((panel) => groupKey(panel) === key)!
|
||||
createEffect(() => {
|
||||
if (active()) setMounted(true)
|
||||
})
|
||||
return (
|
||||
<Show when={mounted()}>
|
||||
<div
|
||||
role="tabpanel"
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden flex flex-col"
|
||||
classList={{ hidden: !active() }}
|
||||
inert={!active()}
|
||||
aria-label={selected()?.props.title}
|
||||
>
|
||||
{declaration.render()}
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={single()} 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function groupKey(panel: RegisteredPanel) {
|
||||
return `${panel.session.key}/${panel.plugin}/${panel.props.group ?? panel.key}`
|
||||
}
|
||||
@@ -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,344 @@
|
||||
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 { persisted, Persist } from "@/runtime/persistence/storage"
|
||||
import { extensionTabKey } from "./keys"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
|
||||
|
||||
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
|
||||
icon: () => JSX.Element
|
||||
}
|
||||
type PanelHost = { open(id: string): void; close(id: string): void; active(): string | undefined; visible?(): boolean }
|
||||
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[],
|
||||
services: {} as Record<string, SessionServices | undefined>,
|
||||
})
|
||||
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",
|
||||
"session.header.actions",
|
||||
"session.panel.toolbar",
|
||||
"session.panel.tools",
|
||||
"session.sidebar",
|
||||
]),
|
||||
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,
|
||||
local: ServerConnection.local(connection),
|
||||
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
|
||||
},
|
||||
get services() {
|
||||
return state.services[key]
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
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,
|
||||
platform: {
|
||||
...platform,
|
||||
async saveFile(options, content) {
|
||||
if (platform.saveFile) return platform.saveFile(options, content)
|
||||
const url = URL.createObjectURL(new Blob([content], { type: "application/octet-stream" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = options.defaultPath ?? "download"
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
return true
|
||||
},
|
||||
},
|
||||
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,
|
||||
intl: language.intl,
|
||||
plural: (key, count, params) => language.plural(key as Parameters<typeof language.plural>[0], count, params),
|
||||
t: (key, params) => 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: command.reference ?? `${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: {
|
||||
toast: {
|
||||
show: (options) =>
|
||||
showToast({ title: options.title, description: options.message, variant: options.variant }),
|
||||
},
|
||||
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 panel = state.panels.find(
|
||||
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
|
||||
)
|
||||
const key = panel?.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 =
|
||||
state.panels.find(
|
||||
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
|
||||
)?.key ?? extensionTabKey(id, localID)
|
||||
const host = hosts.get(session.key)
|
||||
if (!host) return false
|
||||
host.close(key)
|
||||
return true
|
||||
},
|
||||
selected: (localID, session) => {
|
||||
const panel = state.panels.find(
|
||||
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
|
||||
)
|
||||
return !!panel && hosts.get(session.key)?.active() === panel.key
|
||||
},
|
||||
visible: (localID, session) => {
|
||||
const panel = state.panels.find(
|
||||
(panel) => panel.plugin === id && panel.props.id === localID && panel.session.key === session.key,
|
||||
)
|
||||
const host = hosts.get(session.key)
|
||||
return !!panel && host?.active() === panel.key && (host.visible?.() ?? true)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
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, services?: SessionServices) {
|
||||
hosts.set(session.key, host)
|
||||
setState("services", session.key, services)
|
||||
return () => {
|
||||
if (hosts.get(session.key) === host) {
|
||||
hosts.delete(session.key)
|
||||
setState("services", session.key, undefined)
|
||||
}
|
||||
}
|
||||
},
|
||||
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,136 @@
|
||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||
import { useOptionalDesktopExtensions } from "./provider"
|
||||
import { ExtensionSlot } from "./slot"
|
||||
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
|
||||
|
||||
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
|
||||
services?: SessionServices
|
||||
active?: () => string | undefined
|
||||
}) {
|
||||
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.active?.() ?? input.tabs().active(),
|
||||
visible: () => input.services?.view.panel.opened() ?? true,
|
||||
},
|
||||
input.services,
|
||||
),
|
||||
)
|
||||
})
|
||||
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) &&
|
||||
panels().find((panel) => panel.key === key)?.props.initial !== "closed",
|
||||
)
|
||||
if (added.length || current.length !== tabs.all().length) tabs.setAll([...current, ...added])
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => input.tabs().all(),
|
||||
(current, previous) => {
|
||||
previous
|
||||
?.filter((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),
|
||||
canClose: (key: string) => panels().find((panel) => panel.key === key)?.props.closable !== false,
|
||||
defaultPanel: () => panels().find((panel) => panel.props.default)?.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>
|
||||
),
|
||||
toolbar: () => (
|
||||
<Show when={session()} keyed>
|
||||
{(session) => <ExtensionSlot path="session.panel.toolbar" input={{ session }} />}
|
||||
</Show>
|
||||
),
|
||||
tools: () => (
|
||||
<Show when={session()} keyed>
|
||||
{(session) => <ExtensionSlot path="session.panel.tools" input={{ session }} />}
|
||||
</Show>
|
||||
),
|
||||
sidebar: () => (
|
||||
<Show when={session()} keyed>
|
||||
{(session) => <ExtensionSlot path="session.sidebar" input={{ session }} />}
|
||||
</Show>
|
||||
),
|
||||
header: () => (
|
||||
<Show when={session()} keyed>
|
||||
{(session) => <ExtensionSlot path="session.header.actions" input={{ session }} />}
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExtensions = ReturnType<typeof useExtensionPanels>
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
const render = (value: () => JSX.Element) => {
|
||||
const mounted = createRoot((dispose) => ({ dispose, view: value() }), owner)
|
||||
onCleanup(mounted.dispose)
|
||||
return mounted.view
|
||||
}
|
||||
onCleanup(
|
||||
host.register({
|
||||
key: panel.reference ?? extensionTabKey(claim.plugin, panel.id),
|
||||
plugin: claim.plugin,
|
||||
session,
|
||||
props: panel,
|
||||
render: () => render(() => panel.children),
|
||||
icon: () => render(() => panel.icon),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useOptionalDesktopExtensions } from "./provider"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
export function createSessionServices(session: SessionModel): SessionServices {
|
||||
const file = useFile()
|
||||
const annotations = useComments()
|
||||
const draft = useComposerState()
|
||||
const location = useWorkspaceLocation()
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const extensions = useOptionalDesktopExtensions()
|
||||
const server = useServer()
|
||||
return {
|
||||
display: { wrapDiff: settings.general.mobileDiffWrap },
|
||||
files: {
|
||||
...file,
|
||||
get directory() {
|
||||
return location().directory
|
||||
},
|
||||
},
|
||||
annotations,
|
||||
draft: { context: draft.context },
|
||||
view: {
|
||||
ready: layout.ready,
|
||||
desktop: session.isDesktop,
|
||||
tabs: {
|
||||
all: () => session.layout.tabs().all(),
|
||||
active: session.tabs.activeTab,
|
||||
open: (reference) => session.layout.tabs().open(reference),
|
||||
close: (reference) => session.layout.tabs().close(reference),
|
||||
canClose: (reference) =>
|
||||
extensions?.state.panels.find(
|
||||
(panel) =>
|
||||
panel.key === reference &&
|
||||
panel.session.sessionID === session.identity.sessionID() &&
|
||||
panel.session.server.id === server.key,
|
||||
)?.props.closable !== false,
|
||||
setActive: (reference) => session.layout.tabs().setActive(reference),
|
||||
preview: () => session.layout.tabs().preview(),
|
||||
previewTab: (reference) => session.layout.tabs().previewTab(reference),
|
||||
},
|
||||
panel: {
|
||||
opened: () => session.layout.view().reviewPanel.opened(),
|
||||
open: (source) => session.layout.view().reviewPanel.open(source),
|
||||
close: () => session.layout.view().reviewPanel.close(),
|
||||
toggle: () => session.layout.view().reviewPanel.toggle(),
|
||||
source: () => session.layout.view().reviewPanel.source(),
|
||||
},
|
||||
sidebar: { ...layout.fileTree, allowed: settings.visibility.fileTree },
|
||||
scroll: (key) => session.layout.view().scroll(key),
|
||||
setScroll: (key, value) => session.layout.view().setScroll(key, value),
|
||||
},
|
||||
get project() {
|
||||
const project = session.project()
|
||||
return project && { id: project.id, directory: project.worktree, name: project.name, vcs: project.vcs }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ 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 }
|
||||
@@ -122,6 +124,8 @@ type PlatformBase = {
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
extensions?: DesktopExtension.Transport
|
||||
extensionPlugins?: readonly Plugin.Definition[]
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -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"
|
||||
@@ -48,6 +49,9 @@ 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"
|
||||
import { ExtensionPanelContent } from "@/extensions/content"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
@@ -58,6 +62,7 @@ function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
extensions: SessionExtensions
|
||||
canReview: boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
@@ -81,6 +86,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)")
|
||||
@@ -174,6 +180,9 @@ export function SessionSidePanel(props: {
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => true,
|
||||
browser: props.browser.attached,
|
||||
extensions: extensions.keys,
|
||||
defaultPanel: extensions.defaultPanel,
|
||||
canClose: extensions.canClose,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
@@ -225,7 +234,13 @@ 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" &&
|
||||
!isSessionBrowserTab(active) &&
|
||||
!extensions.keys().includes(active)
|
||||
)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
@@ -365,6 +380,27 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={extensions.keys().includes(tab)}>
|
||||
<Show when={extensions.panels().find((panel) => panel.key === tab)}>
|
||||
{(panel) => (
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
onTabClose={panel().props.closable === false ? undefined : tabs().close}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Show when={panel().props.loading} fallback={panel().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>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
@@ -436,7 +472,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={props.browser.available() || extensions.hasActions()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
value={
|
||||
@@ -491,12 +527,15 @@ 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>
|
||||
<Show when={props.browser.available()}>
|
||||
<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>
|
||||
</Show>
|
||||
{extensions.actions()}
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
@@ -552,6 +591,8 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<ExtensionPanelContent panels={extensions.panels()} active={activeTab()} />
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SortableTab(props: {
|
||||
tab: string
|
||||
index: number
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabClose?: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
/** Replaces the file visual for non-file tabs such as the browser. */
|
||||
children?: JSX.Element
|
||||
@@ -46,27 +46,29 @@ export function SortableTab(props: {
|
||||
value={props.tab}
|
||||
id={props.id}
|
||||
aria-controls={props.ariaControls}
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onMiddleClick={props.onTabClose ? () => props.onTabClose?.(props.tab) : undefined}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<Tabs.CloseButton
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show when={props.onTabClose}>
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<Tabs.CloseButton
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose?.(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { isExtensionTab } from "@/extensions/keys"
|
||||
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
@@ -26,6 +27,9 @@ type TabsInput = {
|
||||
hasReview?: Accessor<boolean>
|
||||
fileBrowser?: Accessor<boolean>
|
||||
browser?: Accessor<boolean>
|
||||
extensions?: Accessor<readonly string[]>
|
||||
defaultPanel?: Accessor<string | undefined>
|
||||
canClose?: (key: string) => boolean
|
||||
}
|
||||
|
||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||
@@ -50,7 +54,9 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (input.extensions?.().includes(tab)) return [tab]
|
||||
if (tab === "context" || tab === "review") return []
|
||||
if (isExtensionTab(tab)) return input.extensions?.().includes(tab) ? [tab] : []
|
||||
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
|
||||
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
@@ -62,14 +68,13 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(() => Array.from(new Set(input.tabs().all().filter((tab) => !!input.pathFromTab(tab)).map(input.normalizeTab))), emptyTabs, {
|
||||
equals: same,
|
||||
})
|
||||
const activeTab = createMemo(() => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (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
|
||||
@@ -77,6 +82,8 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
|
||||
const first = openedTabs()[0]
|
||||
if (first) return first
|
||||
const preferred = input.defaultPanel?.()
|
||||
if (preferred) return preferred
|
||||
if (contextOpen()) return "context"
|
||||
if (review() && hasReview()) return "review"
|
||||
return "empty"
|
||||
@@ -88,6 +95,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
})
|
||||
const closableTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (active && input.extensions?.().includes(active)) return input.canClose?.(active) === false ? undefined : active
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
|
||||
@@ -19,6 +19,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[] = []
|
||||
@@ -30,6 +31,7 @@ export function useSessionModel() {
|
||||
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,6 +85,20 @@ 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) ?? [],
|
||||
defaultPanel: () =>
|
||||
extensions?.state.panels.find(
|
||||
(panel) =>
|
||||
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.props.default,
|
||||
)?.key,
|
||||
canClose: (key) =>
|
||||
extensions?.state.panels.find(
|
||||
(panel) =>
|
||||
panel.session.sessionID === sessionID() && panel.session.server.id === server.key && panel.key === key,
|
||||
)?.props.closable !== false,
|
||||
fileBrowser: () => isDesktop() && !!sessionID(),
|
||||
// Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows.
|
||||
browser: () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ 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")
|
||||
@@ -148,11 +149,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()}
|
||||
|
||||
@@ -34,6 +34,8 @@ 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"
|
||||
import { createSessionServices } from "@/extensions/workspace"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -49,6 +51,14 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const extensions = useExtensionPanels({
|
||||
services: createSessionServices(session),
|
||||
active: session.tabs.activeTab,
|
||||
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({
|
||||
@@ -249,6 +259,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
headerActions={extensions.header()}
|
||||
hideHeader={!isDesktop()}
|
||||
session={session}
|
||||
background={composer.requests.background}
|
||||
@@ -288,6 +299,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 +391,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview
|
||||
review={review}
|
||||
browser={browser}
|
||||
extensions={extensions}
|
||||
present={store.sideReviewPresent}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -335,6 +335,7 @@ export function SessionSummaryPanel(props: {
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
headerActions?: JSX.Element
|
||||
hideHeader?: boolean
|
||||
session: TimelineSessionSource
|
||||
background: SessionBackground
|
||||
@@ -795,6 +796,7 @@ function MessageTimelineView(
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{props.search}
|
||||
<SessionContextUsage placement="bottom" />
|
||||
{props.headerActions}
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"]
|
||||
|
||||
@@ -31,6 +32,7 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<ExtensionSlot path="settings.experimental" />
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,8 @@ 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"
|
||||
@@ -80,7 +82,11 @@ function AppLayout(props: ParentProps) {
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
<DesktopExtensionsProvider>
|
||||
<ExtensionSlot path="app">
|
||||
<Shell>{props.children}</Shell>
|
||||
</ExtensionSlot>
|
||||
</DesktopExtensionsProvider>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
|
||||
@@ -52,7 +52,7 @@ export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
|
||||
|
||||
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
|
||||
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
|
||||
export type ReviewPanelSource = "context-button" | "other"
|
||||
export type ReviewPanelSource = string
|
||||
export type TabPanes = {
|
||||
terminalOpened: Accessor<boolean>
|
||||
setTerminalOpened(opened: boolean): void
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return state
|
||||
}
|
||||
|
||||
function withPath(input: string, action: (file: string) => unknown) {
|
||||
function withPath<Value>(input: string, action: (file: string) => Value): Value {
|
||||
return action(path.normalize(input))
|
||||
}
|
||||
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { createCornerImages } from "./native/corners"
|
||||
import type { BrowserNetwork } from "./browser/network"
|
||||
import { destinationOrigin, normalizeURL } from "./browser/policy"
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
|
||||
export const mainExtensions: readonly MainPlugin.Entry[] = []
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,38 @@ 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 extensions = createMainExtensionHost(mainExtensions, (win, event) =>
|
||||
emitIpcEvent(win.webContents, new ExtensionEvent({ event })),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => extensions.dispose()))
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
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("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),
|
||||
BrowserPane: ({ request }, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
|
||||
+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))
|
||||
@@ -4,6 +4,7 @@ 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,6 +24,7 @@ export type UpdaterAPI = {
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
extensions: DesktopExtension.Transport
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
browserPane: {
|
||||
|
||||
@@ -23,6 +23,28 @@ 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: {
|
||||
|
||||
@@ -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"
|
||||
@@ -29,6 +24,7 @@ export function createDesktopPlatform(
|
||||
os,
|
||||
version: windowState.version,
|
||||
windowID: windowState.id,
|
||||
extensions: api.extensions,
|
||||
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
|
||||
...createDesktopStorage(api),
|
||||
browserPane: {
|
||||
|
||||
@@ -3,6 +3,12 @@ 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 ExtensionEvent extends Schema.TaggedClass<ExtensionEvent>()("ExtensionEvent", {
|
||||
event: DesktopExtension.Event,
|
||||
}) {}
|
||||
|
||||
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
|
||||
bindingID: Schema.String,
|
||||
@@ -46,6 +52,7 @@ export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("Storag
|
||||
}) {}
|
||||
|
||||
export const DesktopEvent = Schema.Union([
|
||||
ExtensionEvent,
|
||||
BrowserPaneEvent,
|
||||
DeepLinksOpened,
|
||||
MenuCommandTriggered,
|
||||
@@ -59,4 +66,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, BrowserPaneRpc, 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,
|
||||
})
|
||||
@@ -3,7 +3,7 @@ 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 { createCornerImages } from "../../src/main/native/corners"
|
||||
|
||||
export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
const children = win.contentView.children.length
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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
|
||||
|
||||
Active routes expose `session.services`: workspace file caches and selection,
|
||||
draft attachments, annotations, tab references, scroll state, and panel/sidebar
|
||||
layout controls. These services exist while the session route is mounted. Stable
|
||||
session identity and the public client remain available while its shell tab is open.
|
||||
Feature queries stay in the extension and use TanStack Query.
|
||||
|
||||
Panels can use a shared `reference` (for example a `file://` resource) so existing
|
||||
document producers can select them. `initial: "closed"` separates availability from
|
||||
opening, `default` selects a fallback, and `closable: false` declares a pinned panel.
|
||||
`view.tabs.canClose(reference)` lets a feature count user-opened tabs without knowing
|
||||
which extensions supply pinned defaults. The host retains activated `group` content
|
||||
while a declaration in that group exists, so replacing a file preview preserves its
|
||||
surrounding sidebar. Group identity includes the server and session.
|
||||
|
||||
Session controls can use `session.header.actions`, `session.panel.toolbar`,
|
||||
`session.panel.tools`, and `session.sidebar`. Each receives the session input and
|
||||
uses the shared placement rules. Renderer contexts also expose shared translations,
|
||||
notifications, file export, and available native path actions.
|
||||
|
||||
```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,141 @@
|
||||
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"
|
||||
import type { SessionServices } from "./workspace.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
|
||||
readonly local: 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
|
||||
readonly services?: SessionServices
|
||||
}
|
||||
|
||||
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
|
||||
readonly "session.header.actions": PanelInput
|
||||
readonly "session.panel.toolbar": PanelInput
|
||||
readonly "session.panel.tools": PanelInput
|
||||
readonly "session.sidebar": 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
|
||||
/** Optional shared command reference, e.g. a document opener used by other UI. */
|
||||
readonly reference?: 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 toast: {
|
||||
show(options: { title: string; message?: string; variant?: "error" | "success" | "default" | "loading" }): void
|
||||
}
|
||||
readonly panel: {
|
||||
open(id: string, session: SessionContext): boolean
|
||||
close(id: string, session: SessionContext): boolean
|
||||
selected(id: string, session: SessionContext): boolean
|
||||
visible(id: string, session: SessionContext): boolean
|
||||
}
|
||||
}
|
||||
readonly platform: {
|
||||
readonly platform: "web" | "desktop"
|
||||
readonly os?: "macos" | "windows" | "linux"
|
||||
openPath?(path: string, app?: string): Promise<void>
|
||||
revealPath?(path: string): Promise<boolean>
|
||||
checkAppExists?(app: string): Promise<boolean>
|
||||
saveFile(options: { defaultPath?: string }, content: string): Promise<boolean>
|
||||
writeClipboardText?(text: string): Promise<void>
|
||||
}
|
||||
/** Host copy uses the host language; extension-specific copy can be supplied as a fallback. */
|
||||
readonly i18n: {
|
||||
locale(): string
|
||||
intl(): string
|
||||
t(key: string, params?: Record<string, string | number>): string
|
||||
plural(key: string, count: number, params?: Record<string, string | number>): string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PanelProps {
|
||||
readonly id: string
|
||||
/** Shared resource reference understood by existing document/command producers. */
|
||||
readonly reference?: string
|
||||
readonly closable?: boolean
|
||||
readonly default?: boolean
|
||||
/** Reuse the content owner across related panel instances, such as file previews. */
|
||||
readonly group?: string
|
||||
/** Available declarations can start closed until another UI opens them. */
|
||||
readonly initial?: "open" | "closed"
|
||||
readonly onDoubleClick?: () => void
|
||||
readonly temporary?: boolean
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export interface LineRange {
|
||||
start: number
|
||||
end: number
|
||||
side?: "additions" | "deletions"
|
||||
endSide?: "additions" | "deletions"
|
||||
}
|
||||
export interface TextSelection {
|
||||
startLine: number
|
||||
endLine: number
|
||||
startChar: number
|
||||
endChar: number
|
||||
}
|
||||
export interface FileNode {
|
||||
name: string
|
||||
path: string
|
||||
absolute: string
|
||||
type: "file" | "directory"
|
||||
ignored: boolean
|
||||
}
|
||||
export interface FileState {
|
||||
path: string
|
||||
name: string
|
||||
loaded?: boolean
|
||||
loading?: boolean
|
||||
error?: string
|
||||
content?: { type: "text" | "binary"; content: string; encoding?: "base64"; mimeType?: string }
|
||||
}
|
||||
export interface Files {
|
||||
readonly directory: string
|
||||
ready(): boolean
|
||||
normalize(path: string): string
|
||||
tab(path: string): string
|
||||
pathFromTab(tab: string): string | undefined
|
||||
get(path: string): FileState | undefined
|
||||
load(path: string, options?: { force?: boolean }): Promise<void>
|
||||
selectedLines(path: string): LineRange | null | undefined
|
||||
setSelectedLines(path: string, range: LineRange | null): unknown
|
||||
scrollTop(path: string): number | undefined
|
||||
scrollLeft(path: string): number | undefined
|
||||
setScrollTop(path: string, top: number): unknown
|
||||
setScrollLeft(path: string, left: number): unknown
|
||||
searchFiles(query: string, options?: { limit?: number; signal?: AbortSignal }): Promise<string[]>
|
||||
searchFilesAndDirectories(query: string): Promise<string[]>
|
||||
readonly tree: {
|
||||
list(path: string): Promise<void>
|
||||
refresh(path: string): Promise<void>
|
||||
state(path: string): { expanded: boolean; loaded?: boolean; loading?: boolean; error?: string } | undefined
|
||||
children(path: string): FileNode[]
|
||||
expand(path: string, options?: { list?: boolean }): unknown
|
||||
collapse(path: string): unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
time: number
|
||||
file: string
|
||||
selection: LineRange
|
||||
comment: string
|
||||
}
|
||||
export interface Annotations {
|
||||
all(): Annotation[]
|
||||
list(file: string): Annotation[]
|
||||
add(input: Omit<Annotation, "id" | "time">): Annotation
|
||||
update(file: string, id: string, comment: string): void
|
||||
remove(file: string, id: string): void
|
||||
focus(): { file: string; id: string } | null
|
||||
setFocus(value: { file: string; id: string } | null): unknown
|
||||
clearFocus(): void
|
||||
}
|
||||
export interface DraftFile {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
preview?: string
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
}
|
||||
export interface Draft {
|
||||
readonly context: {
|
||||
add(input: DraftFile): unknown
|
||||
updateComment(path: string, id: string, input: { comment?: string; preview?: string }): unknown
|
||||
removeComment(path: string, id: string): unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
ready(): boolean
|
||||
desktop(): boolean
|
||||
readonly tabs: {
|
||||
all(): string[]
|
||||
active(): string | undefined
|
||||
open(reference: string): Promise<void>
|
||||
setActive(reference: string): void
|
||||
close(reference: string): void
|
||||
canClose(reference: string): boolean
|
||||
preview(): string | undefined
|
||||
previewTab(reference: string): void
|
||||
}
|
||||
readonly panel: {
|
||||
opened(): boolean
|
||||
open(source?: string): void
|
||||
close(): void
|
||||
toggle(): void
|
||||
source(): string
|
||||
}
|
||||
readonly sidebar: {
|
||||
allowed(): boolean
|
||||
opened(): boolean
|
||||
width(): number
|
||||
resize(width: number): void
|
||||
toggle(): void
|
||||
tab(): string
|
||||
setTab(value: "changes" | "all"): void
|
||||
}
|
||||
scroll(key: string): { x: number; y: number } | undefined
|
||||
setScroll(key: string, value: { x: number; y: number }): void
|
||||
}
|
||||
|
||||
/** Shared workspace/draft capabilities. Feature queries and presentation remain extension-owned. */
|
||||
export interface SessionServices {
|
||||
/** Shared diff presentation preference, also used by built-in session surfaces. */
|
||||
readonly display: { wrapDiff(): boolean }
|
||||
readonly files: Files
|
||||
readonly annotations: Annotations
|
||||
readonly draft: Draft
|
||||
readonly view: SessionView
|
||||
readonly project: { id: string; directory: string; name?: string; vcs?: string } | undefined
|
||||
}
|
||||
|
||||
export function selectionFromLines(range: LineRange): TextSelection {
|
||||
return {
|
||||
startLine: Math.min(range.start, range.end),
|
||||
endLine: Math.max(range.start, range.end),
|
||||
startChar: 0,
|
||||
endChar: 0,
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
import { Show, type JSX, type ParentProps } from "solid-js"
|
||||
import "./session-review-v2.css"
|
||||
import "./session-mobile-file-panel.css"
|
||||
|
||||
export function SessionFilePanelV2(props: {
|
||||
sidebar?: JSX.Element
|
||||
@@ -37,3 +38,14 @@ export function SessionFilePanelV2(props: {
|
||||
export function SessionFilePanelV2Empty(props: ParentProps) {
|
||||
return <div data-slot="session-review-v2-empty">{props.children}</div>
|
||||
}
|
||||
|
||||
export function SessionMobileFilePanel(props: ParentProps<{ browsing: boolean; header: JSX.Element }>) {
|
||||
return (
|
||||
<div data-slot="session-mobile-files" data-browsing={props.browsing} class="flex h-full min-h-0 flex-col">
|
||||
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
|
||||
{props.header}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
[data-slot="session-mobile-files"]
|
||||
[data-slot="session-mobile-files-header"]
|
||||
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"]
|
||||
[data-slot="tabs-v2-list"] {
|
||||
position: static;
|
||||
|
||||
&::before {
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-mobile-files"] [data-component="line-comment-v2"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
[data-slot="session-mobile-files"][data-browsing="true"] {
|
||||
[data-component="session-review-v2-sidebar-root"] {
|
||||
width: 100%;
|
||||
}
|
||||
[data-slot="session-review-v2-sidebar"] {
|
||||
width: 100% !important;
|
||||
border-inline-end: 0;
|
||||
}
|
||||
[data-slot="session-review-v2-preview"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] {
|
||||
width: 32px;
|
||||
height: 36px;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -38,11 +38,13 @@
|
||||
"./avatar": "./src/data-display/avatar/avatar.tsx",
|
||||
"./badge": "./src/data-display/badge/badge.tsx",
|
||||
"./button": "./src/actions/button/button.tsx",
|
||||
"./file-tree-item": "./src/components/file-tree-item.tsx",
|
||||
"./button.css": "./src/actions/button/button.css",
|
||||
"./checkbox": "./src/forms/checkbox/checkbox.tsx",
|
||||
"./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,8 @@
|
||||
import { Root } from "@kobalte/core/button"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import "../styles/file-tree.css"
|
||||
|
||||
/** Interactive row for the shared file-tree and file-search presentation. */
|
||||
export function FileTreeItem(props: ComponentProps<typeof Root>) {
|
||||
return <Root {...props} data-slot="file-tree-v2-row" />
|
||||
}
|
||||
@@ -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