mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 10:26:25 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe5420755 | ||
|
|
a531a61a72 | ||
|
|
d4539ff55c | ||
|
|
7acdea1437 | ||
|
|
890f8fe863 | ||
|
|
fa4bce6f7a | ||
|
|
d8804ffd65 | ||
|
|
7bb391582b | ||
|
|
07dcde43c6 | ||
|
|
66b16d40f0 | ||
|
|
de7ab3b082 | ||
|
|
29a05fa343 | ||
|
|
0dc7aa2411 | ||
|
|
b5222ef904 | ||
|
|
30c25008ac | ||
|
|
7466567c51 | ||
|
|
39310ee609 | ||
|
|
cc40f5844c |
@@ -63,7 +63,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
@@ -426,7 +426,9 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/plugin-browser-desktop": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
@@ -597,8 +599,12 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron": "42.10.1",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.2.2",
|
||||
"vite-plugin-solid": "2.11.14",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
@@ -628,6 +634,28 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin-browser-desktop": {
|
||||
"name": "@opencode/plugin-browser-desktop",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"lighthouse": "13.4.1",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"electron": "42.10.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
"name": "@opencode/posts",
|
||||
"dependencies": {
|
||||
@@ -2215,6 +2243,8 @@
|
||||
|
||||
"@opencode/plugin-browser": ["@opencode/plugin-browser@workspace:packages/plugin-browser"],
|
||||
|
||||
"@opencode/plugin-browser-desktop": ["@opencode/plugin-browser-desktop@workspace:packages/plugin-browser-desktop"],
|
||||
|
||||
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
|
||||
|
||||
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html><head><meta charset="UTF-8" /><style>html,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}</style></head><body><div id="root"></div><script type="module" src="./fixture.tsx"></script></body></html>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { AppBaseProviders, AppInterface } from "../../src/app"
|
||||
import { PlatformProvider } from "../../src/runtime/platform/platform"
|
||||
import { createWebPlatform } from "../../src/runtime/platform/web"
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
import { Plugin } from "@opencode/plugin/desktop"
|
||||
import { Panel } from "@opencode/plugin/desktop/solid"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { Stack, Text } from "@opencode/ui/layout"
|
||||
import { createMemoryHistory, MemoryRouter } from "@solidjs/router"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
|
||||
const plugin = Plugin.define({
|
||||
id: "test.panels",
|
||||
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 @@
|
||||
<!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="./manager-fixture.tsx"></script></body></html>
|
||||
@@ -0,0 +1,53 @@
|
||||
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 { createMemoryHistory, MemoryRouter } from "@solidjs/router"
|
||||
import { render } from "solid-js/web"
|
||||
import { Schema } from "effect"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
|
||||
const web = createWebPlatform("test")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: "/settings" })
|
||||
const inventory = Schema.Array(ExtensionManager.Installed)
|
||||
async function request(name: string, body?: BodyInit) {
|
||||
const response = await fetch(`/__desktop-extensions/${name}`, { method: body ? "POST" : "GET", body })
|
||||
const value: unknown = await response.json()
|
||||
if (!response.ok)
|
||||
throw new ExtensionManager.ManagerError(Schema.decodeUnknownSync(ExtensionManager.Failure)(value).code)
|
||||
return value
|
||||
}
|
||||
const manager: ExtensionManager.Transport = {
|
||||
list: async () => Schema.decodeUnknownSync(inventory)(await request("list")),
|
||||
install: async (data) =>
|
||||
Schema.decodeUnknownSync(inventory)(await request("install", data as Uint8Array<ArrayBuffer>)),
|
||||
installURL: async (url) => Schema.decodeUnknownSync(inventory)(await request("url", JSON.stringify({ url }))),
|
||||
enable: async (id, enabled) =>
|
||||
Schema.decodeUnknownSync(inventory)(await request("enable", JSON.stringify({ id, enabled }))),
|
||||
reload: async (id) => Schema.decodeUnknownSync(inventory)(await request("reload", JSON.stringify({ id }))),
|
||||
source: async (id, revision) =>
|
||||
Schema.decodeUnknownSync(ExtensionManager.Source)(await request("source", JSON.stringify({ id, revision }))),
|
||||
assetURL: (id, revision, path) => `/__desktop-extensions/assets/${id}/${revision}/${path}`,
|
||||
onChange(callback) {
|
||||
const listener = (event: Event) => {
|
||||
if (event instanceof CustomEvent) callback(Schema.decodeUnknownSync(inventory)(event.detail))
|
||||
}
|
||||
window.addEventListener("test-extensions-changed", listener)
|
||||
return () => window.removeEventListener("test-extensions-changed", listener)
|
||||
},
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={{ ...web.platform, extensionManager: manager }}>
|
||||
<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,264 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import { once } from "node:events"
|
||||
import { openDatabase } from "../../../desktop/src/main/storage/database"
|
||||
import { createExtensionManager, readExtensionAsset } from "../../../desktop/src/main/extensions/manager"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import { extensionArchive } from "../../../desktop/test/extensions/fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
const renderer = (version: string) => `
|
||||
const { usePlugin } = require('@opencode/plugin/desktop');
|
||||
const { createEffect, createComponent } = require('solid-js');
|
||||
const { Panel } = require('@opencode/plugin/desktop/solid');
|
||||
const { Schema } = require('effect');
|
||||
module.exports.default = { id: 'test.lifecycle', setup(ctx) {
|
||||
const [documentState, setDocument, ready] = ctx.storage.persist('document', Schema.Struct({ text: Schema.String }), { text: 'empty' }, { legacyKey: 'extension-fixture-legacy' });
|
||||
const [state, update] = ctx.storage.memory('counts', { initial: { starts: 0, stops: 0 } });
|
||||
update(state => state.starts++);
|
||||
ctx.lifecycle.own(() => update(state => state.stops++));
|
||||
const marker = (id) => {
|
||||
const output = document.createElement('output'); output.hidden = true; output.dataset.testid = id;
|
||||
createEffect(() => output.textContent = ${JSON.stringify(version)} + ':' + state.starts + ':' + state.stops);
|
||||
return output;
|
||||
};
|
||||
ctx.commands.register(() => [
|
||||
{ id: 'show', title: 'Show extension fixture', bind: 'mod+shift+y', run() { const session = ctx.sessions.current(); if (session) ctx.ui.panel.open('state', session) } },
|
||||
{ id: 'save', title: 'Save extension fixture', bind: 'mod+shift+x', run() { setDocument('text', 'saved') } },
|
||||
]);
|
||||
ctx.ui.slot({ append: 'session.panel', render: () => createComponent(Panel, { id: 'state', title: 'File utilities', group: 'state', get children() { return marker('extension-panel-lifecycle') } }) });
|
||||
ctx.ui.slot({ append: 'app', render() {
|
||||
if (usePlugin().lifecycle.signal !== ctx.lifecycle.signal) throw new Error('Shared plugin context identity was lost');
|
||||
const stored = document.createElement('output'); stored.hidden = true; stored.dataset.testid = 'extension-persisted';
|
||||
createEffect(() => stored.textContent = ready() ? documentState.text : 'loading');
|
||||
return [marker('extension-lifecycle'), stored];
|
||||
} });
|
||||
} };`
|
||||
const payload = Schema.Struct({
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
revision: Schema.optionalKey(Schema.String),
|
||||
enabled: Schema.optionalKey(Schema.Boolean),
|
||||
url: Schema.optionalKey(Schema.String),
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const)
|
||||
test(`manager installs, hot-replaces, reloads, and disables across windows in ${direction}`, async ({
|
||||
page,
|
||||
context,
|
||||
}, info) => {
|
||||
const database = openDatabase(":memory:")
|
||||
const manager = createExtensionManager({
|
||||
db: database.db,
|
||||
fetch,
|
||||
changed(_id, entries) {
|
||||
context.pages().forEach((page) => {
|
||||
void page.evaluate(
|
||||
(entries) => window.dispatchEvent(new CustomEvent("test-extensions-changed", { detail: entries })),
|
||||
entries,
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
const archive = async (version: string, code = renderer(version)) =>
|
||||
extensionArchive({
|
||||
version,
|
||||
renderer: code,
|
||||
files: { "assets/style.css": ":root { --installed-extension-marker: 1; }" },
|
||||
manifest: {
|
||||
imports: ["@opencode/plugin/desktop", "@opencode/plugin/desktop/solid", "solid-js", "effect"],
|
||||
style: "assets/style.css",
|
||||
},
|
||||
})
|
||||
const first = await archive("1.0.0")
|
||||
const http = createServer((_request, response) => {
|
||||
response.setHeader("Content-Type", "application/vnd.ocdx")
|
||||
response.end(first)
|
||||
})
|
||||
http.listen(0, "127.0.0.1")
|
||||
await once(http, "listening")
|
||||
const address = http.address()
|
||||
if (!address || typeof address === "string") throw new Error("Fixture address is unavailable")
|
||||
await context.route("**/__desktop-extensions/**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname.split("/").slice(2)
|
||||
try {
|
||||
if (path[0] === "assets") {
|
||||
const bytes = readExtensionAsset(database.db, path[1], path[2], path.slice(3).join("/"))
|
||||
await route.fulfill({ status: bytes ? 200 : 404, body: bytes, contentType: "text/css" })
|
||||
return
|
||||
}
|
||||
const data = Schema.decodeUnknownSync(payload)(
|
||||
JSON.parse(path[0] === "install" ? "{}" : (route.request().postData() ?? "{}")),
|
||||
)
|
||||
const result =
|
||||
path[0] === "install"
|
||||
? await manager.install(route.request().postDataBuffer()!)
|
||||
: path[0] === "url"
|
||||
? await manager.installURL(data.url ?? "")
|
||||
: path[0] === "enable"
|
||||
? manager.enable(data.id ?? "", data.enabled ?? false)
|
||||
: path[0] === "reload"
|
||||
? manager.reload(data.id ?? "")
|
||||
: path[0] === "source"
|
||||
? manager.source(data.id ?? "", data.revision)
|
||||
: manager.list()
|
||||
await route.fulfill({ json: result })
|
||||
} catch (error) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
json: { code: error instanceof ExtensionManager.ManagerError ? error.code : "storage" },
|
||||
})
|
||||
}
|
||||
})
|
||||
const open = async (target: typeof page) => {
|
||||
await mockOpenCodeServer(target, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await installStressSessionTabs(target)
|
||||
await target.addInitScript(() =>
|
||||
localStorage.setItem("opencode.global.dat:extension-fixture-legacy", JSON.stringify({ text: "legacy" })),
|
||||
)
|
||||
await target.goto("/e2e/extensions/manager-fixture.html")
|
||||
await target.getByTestId("settings-screen").getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await expect(target.getByRole("heading", { name: "Install extensions", exact: true })).toBeVisible()
|
||||
const settings = target.getByTestId("settings-screen")
|
||||
const navigation = settings.locator(".settings-nav")
|
||||
await expect(
|
||||
navigation
|
||||
.locator('[data-slot="settings-nav-group"]')
|
||||
.filter({ has: target.getByRole("tab", { name: "Extensions", exact: true }) })
|
||||
.getByRole("tab"),
|
||||
).toHaveText(["Extensions", "Experimental"])
|
||||
await expect(
|
||||
navigation
|
||||
.locator('[data-slot="settings-nav-group"]')
|
||||
.filter({ has: target.getByRole("tab", { name: "Tools", exact: true }) })
|
||||
.getByRole("tab"),
|
||||
).toHaveText(["Providers", "Models", "Tools"])
|
||||
await expect(settings.getByRole("tab", { name: "Desktop", exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Skills", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Install extensions", exact: true })).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await target.evaluate((direction) => {
|
||||
document.documentElement.dir = direction
|
||||
}, direction)
|
||||
}
|
||||
try {
|
||||
await open(page)
|
||||
await page.setViewportSize({ width: 640, height: 900 })
|
||||
await page.getByRole("button", { name: "Extensions", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "Tools", exact: true }).click()
|
||||
await expect(page.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Tools", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "Extensions", exact: true }).click()
|
||||
await expect(page.getByRole("heading", { name: "Install extensions", exact: true })).toBeVisible()
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(page.locator('[data-component="desktop-extension-manager"]').getByRole("status")).toHaveCount(0)
|
||||
await page.screenshot({ path: info.outputPath("extensions-manager-empty.png") })
|
||||
await page
|
||||
.getByRole("textbox", { name: "Extension URL", exact: true })
|
||||
.fill(`http://127.0.0.1:${address.port}/extension.ocdx`)
|
||||
await page.getByRole("textbox", { name: "Extension URL", exact: true }).press("Enter")
|
||||
const enabled = page.getByRole("switch", { name: "Enable File utilities", exact: true })
|
||||
await expect(enabled).toBeChecked()
|
||||
await expect(page.getByTestId("extension-lifecycle")).toHaveText("1.0.0:1:0")
|
||||
await expect(page.getByTestId("extension-persisted")).toHaveText("legacy")
|
||||
await page.keyboard.press("Control+Shift+x")
|
||||
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
|
||||
await page.screenshot({ path: info.outputPath("extensions-manager.png") })
|
||||
await page.getByRole("button", { name: "Reload File utilities", exact: true }).click()
|
||||
await expect(page.getByTestId("extension-lifecycle")).toHaveText("1.0.0:2:1")
|
||||
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
|
||||
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(1)
|
||||
const second = await context.newPage()
|
||||
await open(second)
|
||||
await expect(second.getByTestId("extension-lifecycle")).toHaveText("1.0.0:1:0")
|
||||
await expect(second.getByTestId("extension-persisted")).toHaveText("saved")
|
||||
await second.locator('header a[href$="/ses_smoke_source"]').click()
|
||||
await expect(second.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
await expect(second.getByRole("button", { name: "Toggle review", exact: true })).toBeEnabled()
|
||||
await second.keyboard.press("Control+Shift+y")
|
||||
await expect(second.getByTestId("extension-panel-lifecycle")).toHaveText("1.0.0:1:0")
|
||||
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
|
||||
name: "update.ocdx",
|
||||
mimeType: "application/vnd.ocdx",
|
||||
buffer: Buffer.from(await archive("2.0.0")),
|
||||
})
|
||||
await expect(page.getByTestId("extension-lifecycle")).toHaveText("2.0.0:3:2")
|
||||
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
|
||||
await expect(second.getByTestId("extension-lifecycle")).toHaveText("2.0.0:2:1")
|
||||
await expect(second.getByTestId("extension-panel-lifecycle")).toHaveText("2.0.0:2:1")
|
||||
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
|
||||
name: "broken.ocdx",
|
||||
mimeType: "application/vnd.ocdx",
|
||||
buffer: Buffer.from(
|
||||
await archive(
|
||||
"3.0.0",
|
||||
`module.exports.default = { id: 'test.lifecycle', setup(ctx) { ctx.ui.slot({ append: 'app', render() { const node = document.createElement('output'); node.hidden = true; node.dataset.testid = 'partial-load'; return node } }); throw new Error('broken update') } }`,
|
||||
),
|
||||
),
|
||||
})
|
||||
await expect(page.getByRole("alert")).toHaveText("Unable to activate File utilities.")
|
||||
await expect(page.getByTestId("extension-lifecycle")).toHaveText("2.0.0:3:2")
|
||||
await expect(page.getByTestId("partial-load")).toHaveCount(0)
|
||||
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(1)
|
||||
await page.getByLabel("Extension files", { exact: true }).setInputFiles({
|
||||
name: "fixed.ocdx",
|
||||
mimeType: "application/vnd.ocdx",
|
||||
buffer: Buffer.from(await archive("4.0.0")),
|
||||
})
|
||||
await expect(page.getByTestId("extension-lifecycle")).toHaveText("4.0.0:4:3")
|
||||
await second.keyboard.press("Control+,")
|
||||
await second.getByTestId("settings-screen").getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await page
|
||||
.locator('[data-component="settings-row"]')
|
||||
.filter({ has: enabled })
|
||||
.locator('[data-slot="switch-control"]')
|
||||
.click()
|
||||
await expect(second.getByRole("switch", { name: "Enable File utilities", exact: true })).not.toBeChecked()
|
||||
await expect(page.getByTestId("extension-lifecycle")).toHaveCount(0)
|
||||
await expect(page.getByTestId("extension-persisted")).toHaveCount(0)
|
||||
await expect(second.getByTestId("extension-lifecycle")).toHaveCount(0)
|
||||
await expect(page.locator('link[href*="/__desktop-extensions/assets/"]')).toHaveCount(0)
|
||||
await enabled.press("Space")
|
||||
await expect(page.getByTestId("extension-lifecycle")).toHaveText("4.0.0:5:4")
|
||||
await expect(page.getByTestId("extension-persisted")).toHaveText("saved")
|
||||
await expect(second.getByTestId("extension-lifecycle")).toHaveText("4.0.0:4:3")
|
||||
const archives = await Promise.all(
|
||||
["Text helpers", "Path helpers"].map(async (name, index) => ({
|
||||
name: `helper-${index}.ocdx`,
|
||||
bytes: Buffer.from(await extensionArchive({ id: `test.helper-${index}`, manifest: { name } })).toString(
|
||||
"base64",
|
||||
),
|
||||
})),
|
||||
)
|
||||
const drop = await page.evaluateHandle((archives) => {
|
||||
const transfer = new DataTransfer()
|
||||
archives.forEach((archive) =>
|
||||
transfer.items.add(
|
||||
new File([Uint8Array.from(atob(archive.bytes), (char) => char.charCodeAt(0))], archive.name),
|
||||
),
|
||||
)
|
||||
return transfer
|
||||
}, archives)
|
||||
await page.locator(".desktop-extension-drop").dispatchEvent("drop", { dataTransfer: drop })
|
||||
await expect(page.getByRole("switch", { name: "Enable Text helpers", exact: true })).toBeChecked()
|
||||
await expect(second.getByRole("switch", { name: "Enable Path helpers", exact: true })).toBeChecked()
|
||||
await drop.dispose()
|
||||
await second.close()
|
||||
} finally {
|
||||
http.close()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
@@ -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:test.panels: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")
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } 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.use({ viewport: { width: 1280, height: 720 }, serviceWorkers: "block" })
|
||||
test("server tools remain available without a native extension manager", 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("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Skills", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toHaveCount(0)
|
||||
await page.screenshot({ path: info.outputPath("tools-settings.png") })
|
||||
})
|
||||
@@ -28,7 +28,9 @@ resizing reduces the effective row capacity. The report records actual retained
|
||||
rows and the first retained fixture record rather than assuming 10,000 rows. Completion
|
||||
requires the final marker in Ghostty and completion of its write callbacks, not
|
||||
just WebSocket delivery. Teardown requires Home readiness and the final serialized
|
||||
snapshot. Input, focus, resizing, and native process survival are checked.
|
||||
snapshot, capped at the production limit of 2,000 scrollback rows plus the screen.
|
||||
The snapshot must contain the same ordered workload records as that retained tail.
|
||||
Input, focus, resizing, and native process survival are checked.
|
||||
|
||||
`probe.ts` is included only by this benchmark build. It observes actual writes,
|
||||
renderer calls, and serialization. Chrome `TaskDuration` measures renderer task
|
||||
|
||||
@@ -236,6 +236,15 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
|
||||
const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`)
|
||||
await expect(close).toBeVisible()
|
||||
const cpuBefore = await cdp.send("Performance.getMetrics")
|
||||
const retained = await page.evaluate(() => {
|
||||
const term = window.terminalProbe.term!
|
||||
const buffer = term.buffer.normal
|
||||
const start = Math.max(0, buffer.length - term.rows - 2_000)
|
||||
return Array.from(
|
||||
{ length: buffer.length - start },
|
||||
(_, index) => buffer.getLine(start + index)?.translateToString(true) ?? "",
|
||||
).join("\n")
|
||||
})
|
||||
const start = await page.evaluate(() => performance.now())
|
||||
await close.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
@@ -256,9 +265,11 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
|
||||
cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) *
|
||||
1000
|
||||
const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value)
|
||||
expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual(
|
||||
Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index),
|
||||
)
|
||||
const records = (text: string) => Array.from(text.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))
|
||||
const expected = records(retained)
|
||||
const actual = records(snapshot)
|
||||
expect(actual.length).toBe(expected.length)
|
||||
expect(actual.every((record, index) => record === expected[index])).toBe(true)
|
||||
expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE")
|
||||
await writeFile(
|
||||
path.join(
|
||||
@@ -301,6 +312,7 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
|
||||
await expect
|
||||
.poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols)))
|
||||
.toBe(true)
|
||||
await expect(page.locator('#terminal-panel [data-slot="tabs-list"]')).toHaveCSS("padding-inline-start", "12px")
|
||||
expect(closed).toBe(0)
|
||||
}
|
||||
if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") {
|
||||
|
||||
@@ -169,7 +169,7 @@ test("worktree deletion sends the project location separately from the target",
|
||||
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
test("tools opens without waiting for MCPs", async ({ page }) => {
|
||||
const mcps = Promise.withResolvers<void>()
|
||||
await page.route("**/api/mcp", async (route) => {
|
||||
await mcps.promise
|
||||
@@ -179,9 +179,9 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Tools", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Tools", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
|
||||
mcps.resolve()
|
||||
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
|
||||
|
||||
@@ -55,7 +55,7 @@ for (const viewport of [
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
"Tools",
|
||||
"Experimental",
|
||||
"About",
|
||||
]) {
|
||||
|
||||
@@ -206,9 +206,9 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectAnimation(page, "terminal-panel-size-in")
|
||||
await expectAnimation(page, "auxiliary-panel-size-in")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expectAnimation(page, "terminal-panel-size-out")
|
||||
await expectAnimation(page, "auxiliary-panel-size-out")
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
|
||||
})
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import path from "node:path"
|
||||
|
||||
const packages = path.resolve(import.meta.dirname, "../..")
|
||||
const imports = new Set([
|
||||
"solid-js",
|
||||
"solid-js/store",
|
||||
"solid-js/web",
|
||||
"@tanstack/solid-query",
|
||||
"effect",
|
||||
"@opencode/plugin/desktop",
|
||||
"@opencode/plugin/desktop/solid",
|
||||
"@opencode/plugin/desktop/manager",
|
||||
"@opencode/plugin/desktop/persistence",
|
||||
"@opencode/schema/rpc",
|
||||
"@opencode/client",
|
||||
"@opencode/client/solid",
|
||||
"@opencode/util/encode",
|
||||
"@opencode/util/path",
|
||||
])
|
||||
for (const name of ["ui", "session-ui"]) {
|
||||
const directory = path.join(packages, name)
|
||||
const manifest: { name: string; exports: Record<string, string> } = await Bun.file(
|
||||
path.join(directory, "package.json"),
|
||||
).json()
|
||||
for (const [key, target] of Object.entries(manifest.exports)) {
|
||||
if (!/\.tsx?$/.test(target) || /storybook/.test(key)) continue
|
||||
if (!key.includes("*")) {
|
||||
imports.add(key === "." ? manifest.name : manifest.name + key.slice(1))
|
||||
continue
|
||||
}
|
||||
const [prefix, suffix] = target.split("*")
|
||||
for await (const file of new Bun.Glob(target.slice(2)).scan(directory)) {
|
||||
if (/(\.test\.|\.stories\.|\.story\.)/.test(file)) continue
|
||||
imports.add(
|
||||
manifest.name +
|
||||
key.slice(1).replace("*", ("./" + file.replaceAll("\\", "/")).slice(prefix.length, -suffix.length)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
await Bun.write(
|
||||
path.join(packages, "app/src/extensions/modules.gen.ts"),
|
||||
`// Generated by script/generate-extension-modules.ts. Shared renderer modules only.\nexport const modules: Record<string, () => Promise<unknown>> = {\n${Array.from(
|
||||
imports,
|
||||
)
|
||||
.sort()
|
||||
.map((name) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(name)}),`)
|
||||
.join("\n")}\n}\n`,
|
||||
)
|
||||
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export type {
|
||||
BrowserPaneCommand,
|
||||
BrowserPaneEndpoint,
|
||||
BrowserPaneEvent,
|
||||
BrowserPaneLayout,
|
||||
BrowserPanePlatform,
|
||||
BrowserPaneRegistration,
|
||||
BrowserPaneState,
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
|
||||
@@ -0,0 +1,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.generation}/${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,36 @@
|
||||
import { Schema } from "effect"
|
||||
import { Plugin } from "@opencode/plugin/desktop"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import { evaluateBundle } from "@opencode/plugin/desktop/bundle"
|
||||
|
||||
export async function loadExtension(input: ExtensionManager.Source) {
|
||||
const { modules } = await import("./modules.gen")
|
||||
const shared = new Map(
|
||||
await Promise.all(
|
||||
input.manifest.imports.map(async (name) => {
|
||||
const load = modules[name]
|
||||
if (!load) throw new ExtensionManager.ManagerError("invalidModule")
|
||||
return [name, await load()] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
const value = Schema.decodeUnknownOption(Schema.Struct({ default: Plugin.Definition }))(
|
||||
evaluateBundle(input.source, shared),
|
||||
)
|
||||
if (value._tag === "None" || value.value.default.id !== input.manifest.id)
|
||||
throw new ExtensionManager.ManagerError("invalidModule")
|
||||
const definition = value.value.default
|
||||
return {
|
||||
...definition,
|
||||
setup(context) {
|
||||
if (input.manifest.style) {
|
||||
const stylesheet = document.createElement("link")
|
||||
stylesheet.rel = "stylesheet"
|
||||
stylesheet.href = context.assets.url(input.manifest.style)
|
||||
document.head.append(stylesheet)
|
||||
context.lifecycle.own(() => stylesheet.remove())
|
||||
}
|
||||
return definition.setup(context)
|
||||
},
|
||||
} satisfies Plugin.Definition
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Generated by script/generate-extension-modules.ts. Shared renderer modules only.
|
||||
export const modules: Record<string, () => Promise<unknown>> = {
|
||||
"@opencode/client": () => import("@opencode/client"),
|
||||
"@opencode/client/solid": () => import("@opencode/client/solid"),
|
||||
"@opencode/plugin/desktop": () => import("@opencode/plugin/desktop"),
|
||||
"@opencode/plugin/desktop/manager": () => import("@opencode/plugin/desktop/manager"),
|
||||
"@opencode/plugin/desktop/persistence": () => import("@opencode/plugin/desktop/persistence"),
|
||||
"@opencode/plugin/desktop/solid": () => import("@opencode/plugin/desktop/solid"),
|
||||
"@opencode/schema/rpc": () => import("@opencode/schema/rpc"),
|
||||
"@opencode/session-ui/actions": () => import("@opencode/session-ui/actions"),
|
||||
"@opencode/session-ui/attachment-card": () => import("@opencode/session-ui/attachment-card"),
|
||||
"@opencode/session-ui/basic-tool": () => import("@opencode/session-ui/basic-tool"),
|
||||
"@opencode/session-ui/comment-card": () => import("@opencode/session-ui/comment-card"),
|
||||
"@opencode/session-ui/context": () => import("@opencode/session-ui/context"),
|
||||
"@opencode/session-ui/context/data": () => import("@opencode/session-ui/context/data"),
|
||||
"@opencode/session-ui/context/markdown": () => import("@opencode/session-ui/context/markdown"),
|
||||
"@opencode/session-ui/dock-prompt": () => import("@opencode/session-ui/dock-prompt"),
|
||||
"@opencode/session-ui/document": () => import("@opencode/session-ui/document"),
|
||||
"@opencode/session-ui/file": () => import("@opencode/session-ui/file"),
|
||||
"@opencode/session-ui/file-media": () => import("@opencode/session-ui/file-media"),
|
||||
"@opencode/session-ui/file-search": () => import("@opencode/session-ui/file-search"),
|
||||
"@opencode/session-ui/file-ssr": () => import("@opencode/session-ui/file-ssr"),
|
||||
"@opencode/session-ui/line-comment": () => import("@opencode/session-ui/line-comment"),
|
||||
"@opencode/session-ui/line-comment-annotations": () => import("@opencode/session-ui/line-comment-annotations"),
|
||||
"@opencode/session-ui/line-comment-styles": () => import("@opencode/session-ui/line-comment-styles"),
|
||||
"@opencode/session-ui/markdown": () => import("@opencode/session-ui/markdown"),
|
||||
"@opencode/session-ui/markdown-cache": () => import("@opencode/session-ui/markdown-cache"),
|
||||
"@opencode/session-ui/markdown-stream": () => import("@opencode/session-ui/markdown-stream"),
|
||||
"@opencode/session-ui/message": () => import("@opencode/session-ui/message"),
|
||||
"@opencode/session-ui/message-file": () => import("@opencode/session-ui/message-file"),
|
||||
"@opencode/session-ui/message-nav": () => import("@opencode/session-ui/message-nav"),
|
||||
"@opencode/session-ui/message-part": () => import("@opencode/session-ui/message-part"),
|
||||
"@opencode/session-ui/pierre": () => import("@opencode/session-ui/pierre"),
|
||||
"@opencode/session-ui/pierre/comment-hover": () => import("@opencode/session-ui/pierre/comment-hover"),
|
||||
"@opencode/session-ui/pierre/commented-lines": () => import("@opencode/session-ui/pierre/commented-lines"),
|
||||
"@opencode/session-ui/pierre/diff-selection": () => import("@opencode/session-ui/pierre/diff-selection"),
|
||||
"@opencode/session-ui/pierre/file-find": () => import("@opencode/session-ui/pierre/file-find"),
|
||||
"@opencode/session-ui/pierre/file-runtime": () => import("@opencode/session-ui/pierre/file-runtime"),
|
||||
"@opencode/session-ui/pierre/file-selection": () => import("@opencode/session-ui/pierre/file-selection"),
|
||||
"@opencode/session-ui/pierre/index": () => import("@opencode/session-ui/pierre/index"),
|
||||
"@opencode/session-ui/pierre/media": () => import("@opencode/session-ui/pierre/media"),
|
||||
"@opencode/session-ui/pierre/selection-bridge": () => import("@opencode/session-ui/pierre/selection-bridge"),
|
||||
"@opencode/session-ui/pierre/virtualizer": () => import("@opencode/session-ui/pierre/virtualizer"),
|
||||
"@opencode/session-ui/pierre/worker": () => import("@opencode/session-ui/pierre/worker"),
|
||||
"@opencode/session-ui/session-diff": () => import("@opencode/session-ui/session-diff"),
|
||||
"@opencode/session-ui/session-retry": () => import("@opencode/session-ui/session-retry"),
|
||||
"@opencode/session-ui/session-review": () => import("@opencode/session-ui/session-review"),
|
||||
"@opencode/session-ui/timeline": () => import("@opencode/session-ui/timeline"),
|
||||
"@opencode/session-ui/timeline/detail": () => import("@opencode/session-ui/timeline/detail"),
|
||||
"@opencode/session-ui/timeline/projection": () => import("@opencode/session-ui/timeline/projection"),
|
||||
"@opencode/session-ui/timeline/row": () => import("@opencode/session-ui/timeline/row"),
|
||||
"@opencode/session-ui/tool-count-label": () => import("@opencode/session-ui/tool-count-label"),
|
||||
"@opencode/session-ui/tool-count-summary": () => import("@opencode/session-ui/tool-count-summary"),
|
||||
"@opencode/session-ui/tool-error-card": () => import("@opencode/session-ui/tool-error-card"),
|
||||
"@opencode/session-ui/tool-status-title": () => import("@opencode/session-ui/tool-status-title"),
|
||||
"@opencode/session-ui/v2/line-comment-annotations-v2": () => import("@opencode/session-ui/v2/line-comment-annotations-v2"),
|
||||
"@opencode/session-ui/v2/session-file-panel-v2": () => import("@opencode/session-ui/v2/session-file-panel-v2"),
|
||||
"@opencode/session-ui/v2/session-progress-indicator-v2": () => import("@opencode/session-ui/v2/session-progress-indicator-v2"),
|
||||
"@opencode/session-ui/v2/session-review-empty-changes-v2": () => import("@opencode/session-ui/v2/session-review-empty-changes-v2"),
|
||||
"@opencode/session-ui/v2/session-review-empty-no-git-v2": () => import("@opencode/session-ui/v2/session-review-empty-no-git-v2"),
|
||||
"@opencode/session-ui/v2/session-review-file-preview-v2": () => import("@opencode/session-ui/v2/session-review-file-preview-v2"),
|
||||
"@opencode/session-ui/v2/session-review-v2": () => import("@opencode/session-ui/v2/session-review-v2"),
|
||||
"@opencode/ui/accordion": () => import("@opencode/ui/accordion"),
|
||||
"@opencode/ui/animated-number": () => import("@opencode/ui/animated-number"),
|
||||
"@opencode/ui/app-icon": () => import("@opencode/ui/app-icon"),
|
||||
"@opencode/ui/auxiliary-panel": () => import("@opencode/ui/auxiliary-panel"),
|
||||
"@opencode/ui/avatar": () => import("@opencode/ui/avatar"),
|
||||
"@opencode/ui/badge": () => import("@opencode/ui/badge"),
|
||||
"@opencode/ui/button": () => import("@opencode/ui/button"),
|
||||
"@opencode/ui/card": () => import("@opencode/ui/card"),
|
||||
"@opencode/ui/checkbox": () => import("@opencode/ui/checkbox"),
|
||||
"@opencode/ui/collapsible": () => import("@opencode/ui/collapsible"),
|
||||
"@opencode/ui/context": () => import("@opencode/ui/context"),
|
||||
"@opencode/ui/context-menu": () => import("@opencode/ui/context-menu"),
|
||||
"@opencode/ui/context/dialog": () => import("@opencode/ui/context/dialog"),
|
||||
"@opencode/ui/context/file": () => import("@opencode/ui/context/file"),
|
||||
"@opencode/ui/context/helper": () => import("@opencode/ui/context/helper"),
|
||||
"@opencode/ui/context/i18n": () => import("@opencode/ui/context/i18n"),
|
||||
"@opencode/ui/context/marked": () => import("@opencode/ui/context/marked"),
|
||||
"@opencode/ui/context/marked-base": () => import("@opencode/ui/context/marked-base"),
|
||||
"@opencode/ui/context/marked-parser": () => import("@opencode/ui/context/marked-parser"),
|
||||
"@opencode/ui/context/marked-theme": () => import("@opencode/ui/context/marked-theme"),
|
||||
"@opencode/ui/context/marked-theme-register": () => import("@opencode/ui/context/marked-theme-register"),
|
||||
"@opencode/ui/context/worker-pool": () => import("@opencode/ui/context/worker-pool"),
|
||||
"@opencode/ui/dialog": () => import("@opencode/ui/dialog"),
|
||||
"@opencode/ui/diff-changes": () => import("@opencode/ui/diff-changes"),
|
||||
"@opencode/ui/divider": () => import("@opencode/ui/divider"),
|
||||
"@opencode/ui/dock-surface": () => import("@opencode/ui/dock-surface"),
|
||||
"@opencode/ui/favicon": () => import("@opencode/ui/favicon"),
|
||||
"@opencode/ui/field": () => import("@opencode/ui/field"),
|
||||
"@opencode/ui/file-icon": () => import("@opencode/ui/file-icon"),
|
||||
"@opencode/ui/file-tree-item": () => import("@opencode/ui/file-tree-item"),
|
||||
"@opencode/ui/font": () => import("@opencode/ui/font"),
|
||||
"@opencode/ui/hooks": () => import("@opencode/ui/hooks"),
|
||||
"@opencode/ui/hover-card": () => import("@opencode/ui/hover-card"),
|
||||
"@opencode/ui/i18n/am": () => import("@opencode/ui/i18n/am"),
|
||||
"@opencode/ui/i18n/ar": () => import("@opencode/ui/i18n/ar"),
|
||||
"@opencode/ui/i18n/az": () => import("@opencode/ui/i18n/az"),
|
||||
"@opencode/ui/i18n/bg": () => import("@opencode/ui/i18n/bg"),
|
||||
"@opencode/ui/i18n/bn": () => import("@opencode/ui/i18n/bn"),
|
||||
"@opencode/ui/i18n/br": () => import("@opencode/ui/i18n/br"),
|
||||
"@opencode/ui/i18n/bs": () => import("@opencode/ui/i18n/bs"),
|
||||
"@opencode/ui/i18n/ca": () => import("@opencode/ui/i18n/ca"),
|
||||
"@opencode/ui/i18n/cs": () => import("@opencode/ui/i18n/cs"),
|
||||
"@opencode/ui/i18n/da": () => import("@opencode/ui/i18n/da"),
|
||||
"@opencode/ui/i18n/de": () => import("@opencode/ui/i18n/de"),
|
||||
"@opencode/ui/i18n/dv": () => import("@opencode/ui/i18n/dv"),
|
||||
"@opencode/ui/i18n/dz": () => import("@opencode/ui/i18n/dz"),
|
||||
"@opencode/ui/i18n/el": () => import("@opencode/ui/i18n/el"),
|
||||
"@opencode/ui/i18n/en": () => import("@opencode/ui/i18n/en"),
|
||||
"@opencode/ui/i18n/es": () => import("@opencode/ui/i18n/es"),
|
||||
"@opencode/ui/i18n/et": () => import("@opencode/ui/i18n/et"),
|
||||
"@opencode/ui/i18n/fa": () => import("@opencode/ui/i18n/fa"),
|
||||
"@opencode/ui/i18n/fi": () => import("@opencode/ui/i18n/fi"),
|
||||
"@opencode/ui/i18n/fo": () => import("@opencode/ui/i18n/fo"),
|
||||
"@opencode/ui/i18n/fr": () => import("@opencode/ui/i18n/fr"),
|
||||
"@opencode/ui/i18n/he": () => import("@opencode/ui/i18n/he"),
|
||||
"@opencode/ui/i18n/hi": () => import("@opencode/ui/i18n/hi"),
|
||||
"@opencode/ui/i18n/hr": () => import("@opencode/ui/i18n/hr"),
|
||||
"@opencode/ui/i18n/hu": () => import("@opencode/ui/i18n/hu"),
|
||||
"@opencode/ui/i18n/hy": () => import("@opencode/ui/i18n/hy"),
|
||||
"@opencode/ui/i18n/id": () => import("@opencode/ui/i18n/id"),
|
||||
"@opencode/ui/i18n/is": () => import("@opencode/ui/i18n/is"),
|
||||
"@opencode/ui/i18n/it": () => import("@opencode/ui/i18n/it"),
|
||||
"@opencode/ui/i18n/ja": () => import("@opencode/ui/i18n/ja"),
|
||||
"@opencode/ui/i18n/ka": () => import("@opencode/ui/i18n/ka"),
|
||||
"@opencode/ui/i18n/km": () => import("@opencode/ui/i18n/km"),
|
||||
"@opencode/ui/i18n/ko": () => import("@opencode/ui/i18n/ko"),
|
||||
"@opencode/ui/i18n/lo": () => import("@opencode/ui/i18n/lo"),
|
||||
"@opencode/ui/i18n/lt": () => import("@opencode/ui/i18n/lt"),
|
||||
"@opencode/ui/i18n/lv": () => import("@opencode/ui/i18n/lv"),
|
||||
"@opencode/ui/i18n/mk": () => import("@opencode/ui/i18n/mk"),
|
||||
"@opencode/ui/i18n/mn": () => import("@opencode/ui/i18n/mn"),
|
||||
"@opencode/ui/i18n/ms": () => import("@opencode/ui/i18n/ms"),
|
||||
"@opencode/ui/i18n/my": () => import("@opencode/ui/i18n/my"),
|
||||
"@opencode/ui/i18n/ne": () => import("@opencode/ui/i18n/ne"),
|
||||
"@opencode/ui/i18n/nl": () => import("@opencode/ui/i18n/nl"),
|
||||
"@opencode/ui/i18n/no": () => import("@opencode/ui/i18n/no"),
|
||||
"@opencode/ui/i18n/pa": () => import("@opencode/ui/i18n/pa"),
|
||||
"@opencode/ui/i18n/pl": () => import("@opencode/ui/i18n/pl"),
|
||||
"@opencode/ui/i18n/ro": () => import("@opencode/ui/i18n/ro"),
|
||||
"@opencode/ui/i18n/ru": () => import("@opencode/ui/i18n/ru"),
|
||||
"@opencode/ui/i18n/si": () => import("@opencode/ui/i18n/si"),
|
||||
"@opencode/ui/i18n/sk": () => import("@opencode/ui/i18n/sk"),
|
||||
"@opencode/ui/i18n/sl": () => import("@opencode/ui/i18n/sl"),
|
||||
"@opencode/ui/i18n/sq": () => import("@opencode/ui/i18n/sq"),
|
||||
"@opencode/ui/i18n/sr": () => import("@opencode/ui/i18n/sr"),
|
||||
"@opencode/ui/i18n/sv": () => import("@opencode/ui/i18n/sv"),
|
||||
"@opencode/ui/i18n/tg": () => import("@opencode/ui/i18n/tg"),
|
||||
"@opencode/ui/i18n/th": () => import("@opencode/ui/i18n/th"),
|
||||
"@opencode/ui/i18n/tk": () => import("@opencode/ui/i18n/tk"),
|
||||
"@opencode/ui/i18n/tr": () => import("@opencode/ui/i18n/tr"),
|
||||
"@opencode/ui/i18n/uk": () => import("@opencode/ui/i18n/uk"),
|
||||
"@opencode/ui/i18n/ur": () => import("@opencode/ui/i18n/ur"),
|
||||
"@opencode/ui/i18n/uz": () => import("@opencode/ui/i18n/uz"),
|
||||
"@opencode/ui/i18n/vi": () => import("@opencode/ui/i18n/vi"),
|
||||
"@opencode/ui/i18n/zh": () => import("@opencode/ui/i18n/zh"),
|
||||
"@opencode/ui/i18n/zht": () => import("@opencode/ui/i18n/zht"),
|
||||
"@opencode/ui/icon": () => import("@opencode/ui/icon"),
|
||||
"@opencode/ui/icon-button": () => import("@opencode/ui/icon-button"),
|
||||
"@opencode/ui/icons/app": () => import("@opencode/ui/icons/app"),
|
||||
"@opencode/ui/icons/file-type": () => import("@opencode/ui/icons/file-type"),
|
||||
"@opencode/ui/icons/provider": () => import("@opencode/ui/icons/provider"),
|
||||
"@opencode/ui/image-preview": () => import("@opencode/ui/image-preview"),
|
||||
"@opencode/ui/inline-input": () => import("@opencode/ui/inline-input"),
|
||||
"@opencode/ui/keybind": () => import("@opencode/ui/keybind"),
|
||||
"@opencode/ui/layout": () => import("@opencode/ui/layout"),
|
||||
"@opencode/ui/line-comment": () => import("@opencode/ui/line-comment"),
|
||||
"@opencode/ui/list": () => import("@opencode/ui/list"),
|
||||
"@opencode/ui/loader": () => import("@opencode/ui/loader"),
|
||||
"@opencode/ui/logo": () => import("@opencode/ui/logo"),
|
||||
"@opencode/ui/menu": () => import("@opencode/ui/menu"),
|
||||
"@opencode/ui/motion-spring": () => import("@opencode/ui/motion-spring"),
|
||||
"@opencode/ui/popover": () => import("@opencode/ui/popover"),
|
||||
"@opencode/ui/progress": () => import("@opencode/ui/progress"),
|
||||
"@opencode/ui/progress-circle": () => import("@opencode/ui/progress-circle"),
|
||||
"@opencode/ui/project-avatar": () => import("@opencode/ui/project-avatar"),
|
||||
"@opencode/ui/provider-icon": () => import("@opencode/ui/provider-icon"),
|
||||
"@opencode/ui/radio": () => import("@opencode/ui/radio"),
|
||||
"@opencode/ui/resize-handle": () => import("@opencode/ui/resize-handle"),
|
||||
"@opencode/ui/resize-state": () => import("@opencode/ui/resize-state"),
|
||||
"@opencode/ui/scroll-view": () => import("@opencode/ui/scroll-view"),
|
||||
"@opencode/ui/segmented-control": () => import("@opencode/ui/segmented-control"),
|
||||
"@opencode/ui/select": () => import("@opencode/ui/select"),
|
||||
"@opencode/ui/spinner": () => import("@opencode/ui/spinner"),
|
||||
"@opencode/ui/split-button": () => import("@opencode/ui/split-button"),
|
||||
"@opencode/ui/sticky-accordion-header": () => import("@opencode/ui/sticky-accordion-header"),
|
||||
"@opencode/ui/switch": () => import("@opencode/ui/switch"),
|
||||
"@opencode/ui/tab-state-indicator": () => import("@opencode/ui/tab-state-indicator"),
|
||||
"@opencode/ui/tabs": () => import("@opencode/ui/tabs"),
|
||||
"@opencode/ui/text-field": () => import("@opencode/ui/text-field"),
|
||||
"@opencode/ui/text-input": () => import("@opencode/ui/text-input"),
|
||||
"@opencode/ui/text-reveal": () => import("@opencode/ui/text-reveal"),
|
||||
"@opencode/ui/text-shimmer": () => import("@opencode/ui/text-shimmer"),
|
||||
"@opencode/ui/text-strikethrough": () => import("@opencode/ui/text-strikethrough"),
|
||||
"@opencode/ui/textarea": () => import("@opencode/ui/textarea"),
|
||||
"@opencode/ui/theme": () => import("@opencode/ui/theme"),
|
||||
"@opencode/ui/theme/color": () => import("@opencode/ui/theme/color"),
|
||||
"@opencode/ui/theme/context": () => import("@opencode/ui/theme/context"),
|
||||
"@opencode/ui/theme/default-themes": () => import("@opencode/ui/theme/default-themes"),
|
||||
"@opencode/ui/theme/index": () => import("@opencode/ui/theme/index"),
|
||||
"@opencode/ui/theme/loader": () => import("@opencode/ui/theme/loader"),
|
||||
"@opencode/ui/theme/resolve": () => import("@opencode/ui/theme/resolve"),
|
||||
"@opencode/ui/theme/types": () => import("@opencode/ui/theme/types"),
|
||||
"@opencode/ui/toast": () => import("@opencode/ui/toast"),
|
||||
"@opencode/ui/tooltip": () => import("@opencode/ui/tooltip"),
|
||||
"@opencode/ui/typewriter": () => import("@opencode/ui/typewriter"),
|
||||
"@opencode/ui/wordmark": () => import("@opencode/ui/wordmark"),
|
||||
"@opencode/util/encode": () => import("@opencode/util/encode"),
|
||||
"@opencode/util/path": () => import("@opencode/util/path"),
|
||||
"@tanstack/solid-query": () => import("@tanstack/solid-query"),
|
||||
"effect": () => import("effect"),
|
||||
"solid-js": () => import("solid-js"),
|
||||
"solid-js/store": () => import("solid-js/store"),
|
||||
"solid-js/web": () => import("solid-js/web"),
|
||||
}
|
||||
@@ -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,548 @@
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRoot,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
untrack,
|
||||
batch,
|
||||
useContext,
|
||||
type ParentProps,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, produce, reconcile, type Store, type SetStoreFunction } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import type { Context, PanelProps, SessionContext, SlotClaim, Plugin } from "@opencode/plugin/desktop"
|
||||
import type { Server, StorageOptions } from "@opencode/plugin/desktop/context"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
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, removePersisted } from "@/runtime/persistence/storage"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { terminalFontFamily, useSettings } from "@/settings/model"
|
||||
import { extensionTabKey } from "./keys"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { SessionServices } from "@opencode/plugin/desktop/workspace"
|
||||
|
||||
export type Contribution = Claim<{
|
||||
context: Context
|
||||
generation: number
|
||||
when?: () => boolean
|
||||
render: SlotClaim["render"]
|
||||
}>
|
||||
export type RegisteredPanel = {
|
||||
key: string
|
||||
plugin: string
|
||||
generation: number
|
||||
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 settings = useSettings()
|
||||
const owner = getOwner()
|
||||
const [state, setState] = createStore({
|
||||
claims: [] as Contribution[],
|
||||
panels: [] as RegisteredPanel[],
|
||||
sessions: [] as SessionContext[],
|
||||
servers: [] as Server[],
|
||||
services: {} as Record<string, SessionServices | undefined>,
|
||||
installed: [] as readonly ExtensionManager.Installed[],
|
||||
// Definitions are opaque: storing a factory prevents Solid from merging a
|
||||
// replacement into the previous definition and hiding its identity change.
|
||||
loaded: {} as Record<string, (() => Plugin.Definition) | undefined>,
|
||||
failures: {} as Record<string, boolean | undefined>,
|
||||
managerReady: false,
|
||||
managerError: undefined as ExtensionManager.ErrorCode | 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 persistent = new Map<string, { value: unknown; users: number; reset(): void; dispose(): void }>()
|
||||
const workspaceRemoved = new Set<(value: { serverID: string; directory: string }) => void>()
|
||||
const attempted = new WeakSet<Plugin.Definition>()
|
||||
let instanceID = 0
|
||||
const builtins = () => platform.extensionPlugins ?? []
|
||||
createEffect(() => {
|
||||
const manager = platform.extensionManager
|
||||
if (!manager) return
|
||||
let changed = false
|
||||
let disposed = false
|
||||
onCleanup(
|
||||
manager.onChange((entries) => {
|
||||
changed = true
|
||||
setState("installed", entries)
|
||||
setState("managerReady", true)
|
||||
}),
|
||||
)
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
void manager
|
||||
.list()
|
||||
.then((entries) => {
|
||||
if (disposed || changed) return
|
||||
setState("installed", entries)
|
||||
setState("managerReady", true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed && !changed) {
|
||||
setState("managerError", "storage")
|
||||
setState("managerReady", true)
|
||||
}
|
||||
})
|
||||
})
|
||||
const pending = new Map<string, string>()
|
||||
createEffect(() => {
|
||||
const manager = platform.extensionManager
|
||||
if (!manager) return
|
||||
state.installed.forEach((entry) => {
|
||||
if (!entry.enabled) {
|
||||
pending.delete(entry.id)
|
||||
setState("loaded", entry.id, undefined)
|
||||
setState("failures", entry.id, undefined)
|
||||
return
|
||||
}
|
||||
const token = `${entry.revision}/${entry.generation}`
|
||||
if (pending.get(entry.id) === token) return
|
||||
pending.set(entry.id, token)
|
||||
setState("failures", entry.id, undefined)
|
||||
void (async () => {
|
||||
const { loadExtension } = await import("./load")
|
||||
const value = await loadExtension(await manager.source(entry.id, entry.revision))
|
||||
if (pending.get(entry.id) !== token) return
|
||||
setState("loaded", entry.id, () => () => value)
|
||||
})().catch((error) => {
|
||||
if (pending.get(entry.id) === token) {
|
||||
console.debug("[desktop-extensions] load failed", { id: entry.id, error })
|
||||
setState("failures", entry.id, true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
onCleanup(() => pending.clear())
|
||||
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",
|
||||
"session.auxiliary",
|
||||
"session.mobile.actions",
|
||||
]),
|
||||
claims: state.claims.filter((claim) => claim.render.when?.() ?? true),
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const all = global.servers.list()
|
||||
const available = all.map((connection): Server => {
|
||||
const id = ServerConnection.key(connection)
|
||||
const data = global.ensureServerCtx(connection)
|
||||
return {
|
||||
id,
|
||||
local: ServerConnection.local(connection),
|
||||
get url() {
|
||||
return data.sdk.url
|
||||
},
|
||||
get client() {
|
||||
return data.sdk.api
|
||||
},
|
||||
data: data.data,
|
||||
get compatible() {
|
||||
return !global.servers.health[id]?.incompatible
|
||||
},
|
||||
}
|
||||
})
|
||||
setState("servers", available)
|
||||
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 = available.find((server) => server.id === tab.server)!
|
||||
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 persistTarget = (id: string, key: string, options?: StorageOptions) => {
|
||||
if (!options?.scope)
|
||||
return {
|
||||
...Persist.global(`extension.${id}.${key}`),
|
||||
previousKeys: options?.legacyKey ? [options.legacyKey] : undefined,
|
||||
}
|
||||
const connection = global.servers.list().find((server) => ServerConnection.key(server) === options.scope!.serverID)
|
||||
if (!connection) throw new Error("Extension storage server is unavailable")
|
||||
return {
|
||||
...Persist.serverWorkspace(
|
||||
global.ensureServerCtx(connection).sdk.scope,
|
||||
base64Encode(options.scope.directory),
|
||||
`extension.${id}.${key}`,
|
||||
),
|
||||
previousKeys: options.legacyKey ? [`workspace:${options.legacyKey}`] : undefined,
|
||||
}
|
||||
}
|
||||
const persistFor = <S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
id: string,
|
||||
key: string,
|
||||
schema: S,
|
||||
initial: NoInfer<S["Type"]>,
|
||||
options?: StorageOptions,
|
||||
) => {
|
||||
const target = persistTarget(id, key, options)
|
||||
const name = JSON.stringify([target.storage, target.key])
|
||||
const previous = persistent.get(name)
|
||||
type Value = readonly [Store<S["Type"]>, SetStoreFunction<S["Type"]>, () => boolean]
|
||||
const entry =
|
||||
previous ??
|
||||
createRoot((dispose) => {
|
||||
const pair = persisted(target, schema, initial)
|
||||
return {
|
||||
value: [pair[0], pair[1], pair[3]] as const,
|
||||
users: 0,
|
||||
reset: () => pair[1](reconcile(initial)),
|
||||
dispose,
|
||||
}
|
||||
}, owner)
|
||||
persistent.set(name, entry)
|
||||
entry.users++
|
||||
// Overlapping activation generations share state. Evicted workspace owners
|
||||
// release their hydrated buffers; the persistence layer flushes before disposal.
|
||||
onCleanup(() => {
|
||||
if (--entry.users) return
|
||||
entry.dispose()
|
||||
persistent.delete(name)
|
||||
})
|
||||
return entry.value as Value
|
||||
}
|
||||
|
||||
const activate = (definition: NonNullable<typeof platform.extensionPlugins>[number]) =>
|
||||
createRoot((dispose) => {
|
||||
const lifecycle = createLifecycle()
|
||||
const id = definition.id
|
||||
const generation = ++instanceID
|
||||
let activated = false
|
||||
let nextClaim = 0
|
||||
const context: Context = {
|
||||
assets: {
|
||||
url(path) {
|
||||
const entry = state.installed.find((entry) => entry.id === id)
|
||||
if (!entry || !platform.extensionManager) throw new Error("Extension assets are unavailable")
|
||||
return platform.extensionManager.assetURL(id, entry.revision, path)
|
||||
},
|
||||
},
|
||||
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 },
|
||||
servers: { list: () => state.servers },
|
||||
workspaces: {
|
||||
onRemoved(handler) {
|
||||
workspaceRemoved.add(handler)
|
||||
return lifecycle.own(() => workspaceRemoved.delete(handler))
|
||||
},
|
||||
},
|
||||
fonts: { console: () => terminalFontFamily(settings.appearance.terminalFont()) },
|
||||
storage: {
|
||||
persist: (key, schema, initial, options) => persistFor(id, key, schema, initial, options),
|
||||
remove(key, options) {
|
||||
const target = persistTarget(id, key, options)
|
||||
const name = JSON.stringify([target.storage, target.key])
|
||||
persistent.get(name)?.reset()
|
||||
removePersisted(target, platform)
|
||||
target.previousKeys?.forEach((key) => removePersisted({ ...target, key }, platform))
|
||||
},
|
||||
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}/${generation}/${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,
|
||||
when: command.when,
|
||||
onSelect: () => {
|
||||
void command.run()
|
||||
},
|
||||
})),
|
||||
)
|
||||
return dispose
|
||||
}),
|
||||
)
|
||||
},
|
||||
dispatch: (commandID) => commands.trigger(`${id}.${commandID}`),
|
||||
keys: commands.keybindParts,
|
||||
matches: commands.matches,
|
||||
},
|
||||
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}/${generation}/${nextClaim++}`,
|
||||
plugin: id,
|
||||
placement: { kind, target: claim[kind]! },
|
||||
render: { context, generation, 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 {
|
||||
if (activated) platform.extensions?.release(id)
|
||||
}
|
||||
})
|
||||
try {
|
||||
const cleanup = definition.setup(context)
|
||||
if (cleanup) lifecycle.own(cleanup)
|
||||
activated = true
|
||||
return dispose
|
||||
} catch (error) {
|
||||
dispose()
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const definitions = [
|
||||
...builtins(),
|
||||
...Object.values(state.loaded)
|
||||
.flatMap((load) => (load ? [load()] : []))
|
||||
.filter((value) => !builtins().some((builtin) => builtin.id === value.id)),
|
||||
]
|
||||
Array.from(instances).forEach(([id, instance]) => {
|
||||
if (definitions.some((definition) => definition.id === id)) return
|
||||
instance.dispose()
|
||||
instances.delete(id)
|
||||
})
|
||||
definitions.forEach((definition) => {
|
||||
if (instances.get(definition.id)?.definition === definition || attempted.has(definition)) return
|
||||
try {
|
||||
batch(() => {
|
||||
const dispose = untrack(() => activate(definition))
|
||||
instances.get(definition.id)?.dispose()
|
||||
instances.set(definition.id, { definition, dispose })
|
||||
})
|
||||
} catch (error) {
|
||||
if (builtins().includes(definition)) throw error
|
||||
console.debug("[desktop-extensions] activation failed", {
|
||||
id: definition.id,
|
||||
error: error instanceof Error ? error.stack : String(error),
|
||||
})
|
||||
setState("failures", definition.id, true)
|
||||
attempted.add(definition)
|
||||
}
|
||||
})
|
||||
})
|
||||
onCleanup(() => instances.forEach((instance) => instance.dispose()))
|
||||
return {
|
||||
state,
|
||||
resolved,
|
||||
current,
|
||||
transport: platform.extensions,
|
||||
manager: platform.extensionManager,
|
||||
builtins: (): readonly Plugin.Definition[] => builtins(),
|
||||
failed: (id: string) => setState("failures", id, true),
|
||||
workspaceRemoved(value: { serverID: string; directory: string }) {
|
||||
workspaceRemoved.forEach((handler) => handler(value))
|
||||
},
|
||||
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,176 @@
|
||||
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"
|
||||
import type { AuxiliaryPresentation } from "@opencode/plugin/desktop/context"
|
||||
|
||||
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 {
|
||||
hasAuxiliary: () => {
|
||||
const value = host?.resolved().slotted.get("session.auxiliary")
|
||||
return (
|
||||
!!value &&
|
||||
(!!value.replace || value.before.length + value.prepend.length + value.append.length + value.after.length > 0)
|
||||
)
|
||||
},
|
||||
auxiliary: (presentation: AuxiliaryPresentation) => (
|
||||
// A secondary surface can span multiple sessions in the same workspace.
|
||||
// Keep its owner and pass reactive identity instead of keying on the route.
|
||||
<Show when={input.services && session()}>
|
||||
{(current) => (
|
||||
<ExtensionSlot
|
||||
path="session.auxiliary"
|
||||
input={{
|
||||
get session() {
|
||||
return current()
|
||||
},
|
||||
services: input.services!,
|
||||
presentation,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
),
|
||||
mobileActions: () => (
|
||||
<Show when={session()}>
|
||||
{(current) => (
|
||||
<ExtensionSlot
|
||||
path="session.mobile.actions"
|
||||
input={{
|
||||
get session() {
|
||||
return current()
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
),
|
||||
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,103 @@
|
||||
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) => {
|
||||
host.failed(claim.plugin)
|
||||
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,
|
||||
generation: claim.render.generation,
|
||||
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,75 @@
|
||||
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: {
|
||||
auxiliary: {
|
||||
opened: () => session.layout.view().terminal.opened(),
|
||||
open: () => session.layout.view().terminal.open(),
|
||||
close: () => session.layout.view().terminal.close(),
|
||||
toggle: () => session.layout.view().terminal.toggle(),
|
||||
height: () => session.layout.view().terminal.height(),
|
||||
resize: (height) => session.layout.view().terminal.resize(height),
|
||||
placement: settings.general.terminalPlacement,
|
||||
},
|
||||
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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -70,14 +70,6 @@
|
||||
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
|
||||
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
|
||||
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
@@ -147,24 +139,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-in {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-out {
|
||||
from {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::highlight(timeline-search-hit) {
|
||||
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,37 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"settings.tab.tools": "Tools",
|
||||
"settings.tools.description": "Manage tools available on this server",
|
||||
"project.settings.tools.description": "View tools available to this project",
|
||||
"settings.desktopExtensions.installTitle": "Install extensions",
|
||||
"settings.desktopExtensions.drop": "Drop extension files",
|
||||
"settings.desktopExtensions.choose": "Choose one or more .ocdx archives from your computer",
|
||||
"settings.desktopExtensions.browse": "Browse",
|
||||
"settings.desktopExtensions.files": "Extension files",
|
||||
"settings.desktopExtensions.fromURL": "Or install from a URL",
|
||||
"settings.desktopExtensions.placeholder": "https://example.com/my-extension.ocdx",
|
||||
"settings.desktopExtensions.url": "Extension URL",
|
||||
"settings.desktopExtensions.install": "Install",
|
||||
"settings.desktopExtensions.installed": "Installed extensions",
|
||||
"settings.desktopExtensions.empty": "No extensions installed.",
|
||||
"settings.desktopExtensions.builtin": "Built-in",
|
||||
"settings.desktopExtensions.main": "Main process",
|
||||
"settings.desktopExtensions.enable": "Enable {{name}}",
|
||||
"settings.desktopExtensions.reload": "Reload {{name}}",
|
||||
"settings.desktopExtensions.error.files": "Choose one or more .ocdx files.",
|
||||
"settings.desktopExtensions.error.archive": "The file is not a valid .ocdx archive",
|
||||
"settings.desktopExtensions.error.manifest": "This archive must target @opencode/plugin/desktop.",
|
||||
"settings.desktopExtensions.error.path": "The archive contains an invalid file path.",
|
||||
"settings.desktopExtensions.error.size": "Extension is larger than 1 GiB",
|
||||
"settings.desktopExtensions.error.reserved": "Extension ID is reserved by a built-in extension",
|
||||
"settings.desktopExtensions.error.notFound": "Extension not found",
|
||||
"settings.desktopExtensions.error.disabled": "This extension is disabled.",
|
||||
"settings.desktopExtensions.error.module": "The extension entrypoint is invalid or uses an unsupported import.",
|
||||
"settings.desktopExtensions.error.download": "Unable to download the extension.",
|
||||
"settings.desktopExtensions.error.url": "Only HTTP and HTTPS URLs are supported",
|
||||
"settings.desktopExtensions.error.storage": "Unable to update installed extensions.",
|
||||
"settings.desktopExtensions.error.activation": "Unable to activate {{name}}.",
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
|
||||
@@ -1,99 +1,11 @@
|
||||
export * as Persistence from "./schema"
|
||||
|
||||
import { Effect, Option, Predicate, Result, Schema, SchemaAST, SchemaGetter, SchemaParser, Struct } from "effect"
|
||||
|
||||
export type Migrated<S extends Schema.ConstraintCodec<object, unknown>> = {
|
||||
current: S
|
||||
read: Schema.ConstraintDecoder<unknown>
|
||||
}
|
||||
|
||||
export function migrate<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
current: S,
|
||||
read: Schema.ConstraintDecoder<unknown>,
|
||||
): Migrated<S> {
|
||||
return { current, read }
|
||||
}
|
||||
|
||||
function isMigrated<S extends Schema.ConstraintCodec<object, unknown>>(schema: S | Migrated<S>): schema is Migrated<S> {
|
||||
return "current" in schema
|
||||
}
|
||||
|
||||
export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
definition: S | Migrated<S>,
|
||||
initial: NoInfer<S["Type"]>,
|
||||
) {
|
||||
const schema = isMigrated(definition) ? definition.current : definition
|
||||
const read = isMigrated(definition)
|
||||
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
|
||||
: Result.succeed<unknown>
|
||||
const encode = Schema.encodeUnknownSync(schema)
|
||||
return Schema.Unknown.pipe(
|
||||
Schema.decode<Schema.Unknown>({
|
||||
decode: SchemaGetter.transformOrFail((value) =>
|
||||
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
|
||||
),
|
||||
encode: SchemaGetter.transform((value) => encode(value)),
|
||||
}),
|
||||
Schema.decodeTo(Schema.toType(schema)),
|
||||
)
|
||||
}
|
||||
|
||||
// Object-level codecs own their recovery. Plain structs can recover fields independently.
|
||||
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
|
||||
return Object.fromEntries(
|
||||
ast.propertySignatures.flatMap((field) => {
|
||||
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
|
||||
const next = recover(field.type, value[field.name], defaults)
|
||||
if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []
|
||||
return [[field.name, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)
|
||||
return Option.isSome(decoded) ? decoded.value : initial
|
||||
}
|
||||
|
||||
function merge(initial: unknown, value: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
|
||||
)
|
||||
}
|
||||
|
||||
// Unlike a decoding default, a fallback also replaces invalid persisted values.
|
||||
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
|
||||
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
|
||||
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
|
||||
}
|
||||
|
||||
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const field = Schema.optional(schema)
|
||||
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
|
||||
}
|
||||
|
||||
export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields) {
|
||||
return Schema.Struct(fields).mapFields(Struct.map(Schema.mutableKey))
|
||||
}
|
||||
|
||||
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
|
||||
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
|
||||
}
|
||||
|
||||
// Recover individual entries rather than discarding a whole history or collection.
|
||||
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const decode = Schema.decodeUnknownOption(schema)
|
||||
const encode = Schema.encodeSync(schema)
|
||||
return fallback(
|
||||
Schema.Array(Schema.Unknown).pipe(
|
||||
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
|
||||
decode: SchemaGetter.transform((items) => items.flatMap((item) => Option.toArray(decode(item)))),
|
||||
encode: SchemaGetter.transform((items) => items.map((item) => encode(item))),
|
||||
}),
|
||||
),
|
||||
() => [],
|
||||
)
|
||||
}
|
||||
export {
|
||||
Persistence,
|
||||
type Migrated,
|
||||
migrate,
|
||||
withInitial,
|
||||
fallback,
|
||||
optional,
|
||||
struct,
|
||||
record,
|
||||
array,
|
||||
} from "@opencode/plugin/desktop/persistence"
|
||||
|
||||
@@ -24,6 +24,7 @@ type PersistTarget = {
|
||||
scope?: "window"
|
||||
workspaceStorageAliases?: string[]
|
||||
previousKey?: string
|
||||
previousKeys?: string[]
|
||||
key: string
|
||||
}
|
||||
|
||||
@@ -516,7 +517,11 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
if (!isDesktop && !draft) {
|
||||
const current = currentStorage as SyncStorage
|
||||
const sources = [
|
||||
...(config.previousKeys ?? []).map((key) => ({ storage: current, key })),
|
||||
...workspaceAliases.map((storage) => ({ storage: localStorageWithPrefix(storage) })),
|
||||
...workspaceAliases.flatMap((storage) =>
|
||||
(config.previousKeys ?? []).map((key) => ({ storage: localStorageWithPrefix(storage), key })),
|
||||
),
|
||||
...(config.previousKey ? [{ storage: localStorageDirect(), key: config.previousKey }] : []),
|
||||
]
|
||||
|
||||
@@ -552,10 +557,17 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
: undefined
|
||||
const previousStorage = config.previousKey ? (isDesktop ? platform.storage?.() : localStorageDirect()) : undefined
|
||||
const relocationSources = [
|
||||
...(config.previousKeys ?? []).map((key) => ({ storage: current, key })),
|
||||
previousDraftStorage ? { storage: previousDraftStorage } : undefined,
|
||||
...workspaceAliases.map((name) => ({
|
||||
storage: isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name),
|
||||
})),
|
||||
...workspaceAliases.flatMap((name) =>
|
||||
(config.previousKeys ?? []).map((key) => ({
|
||||
storage: isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name),
|
||||
key,
|
||||
})),
|
||||
),
|
||||
previousStorage && config.previousKey ? { storage: previousStorage, key: config.previousKey } : undefined,
|
||||
]
|
||||
.filter((source): source is { storage: SyncStorage | AsyncStorage; key?: string } => !!source?.storage)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
|
||||
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
|
||||
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
|
||||
export type BrowserPaneLayout = {
|
||||
tabID: Browser.TabID
|
||||
visible: boolean
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
background?: readonly [number, number, number, number]
|
||||
radius?: number
|
||||
}
|
||||
|
||||
export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type BrowserPanePlatform = {
|
||||
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { BrowserPanePlatform } from "./browser-pane"
|
||||
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import type { Plugin } from "@opencode/plugin/desktop"
|
||||
import type { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -120,8 +122,9 @@ type PlatformBase = {
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
extensions?: DesktopExtension.Transport
|
||||
extensionPlugins?: readonly Plugin.Definition[]
|
||||
extensionManager?: ExtensionManager.Transport
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
type Server = ReturnType<typeof useServer>
|
||||
|
||||
export type BrowserAttachment = {
|
||||
registration?: BrowserPaneRegistration
|
||||
browser: BrowserPaneState
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Live = {
|
||||
server: Server
|
||||
sessionID: string
|
||||
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
|
||||
tab?: string
|
||||
registration?: BrowserPaneRegistration
|
||||
retry?: ReturnType<typeof setTimeout>
|
||||
attempts: number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
|
||||
// browser survive visiting Settings or another tab and close when the session tab or the setting does.
|
||||
export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({
|
||||
name: "BrowserAttachments",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
)
|
||||
const close = (id: string) => {
|
||||
live.get(id)?.dispose()
|
||||
live.delete(id)
|
||||
setStore(id, undefined)
|
||||
}
|
||||
createEffect(() => {
|
||||
const on = enabled()
|
||||
const tabs = shellTabs.store
|
||||
// The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments.
|
||||
Object.keys(store).forEach((id) => {
|
||||
const entry = live.get(id)
|
||||
if (!entry) return
|
||||
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
|
||||
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
|
||||
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
|
||||
if (current) entry.tab = tabKey(current)
|
||||
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
|
||||
if (on && owned && !entry.server.health?.incompatible) return
|
||||
close(id)
|
||||
})
|
||||
})
|
||||
onCleanup(() => Array.from(live.keys()).forEach(close))
|
||||
|
||||
return {
|
||||
enabled,
|
||||
supported: (server: Server) => !unsupported[server.key],
|
||||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
if (live.has(id)) return
|
||||
const pane = platform.browserPane
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
|
||||
live.set(id, entry)
|
||||
setStore(id, { browser: null })
|
||||
const register = () => {
|
||||
if (entry.registration || live.get(id) !== entry) return
|
||||
// The server's shared transport follows a restarted sidecar's port whether or not any route
|
||||
// for this session is mounted; the connection captured at attach time may predate it.
|
||||
const endpoint = { ...server.conn.http, url: server.ctx.sdk.url }
|
||||
const registration = pane.register({ sessionID, endpoint }, (event) => {
|
||||
if (live.get(id) !== entry) return
|
||||
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
|
||||
if (event.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
return close(id)
|
||||
}
|
||||
if (event.error === "browser.pane.replaced") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
return setStore(id, {
|
||||
registration: undefined,
|
||||
browser: null,
|
||||
error: language.t("session.browser.replaced"),
|
||||
})
|
||||
}
|
||||
// The desktop dropped the attachment (server restart, attach race). Re-register so the
|
||||
// agent's browser tool comes back without a reload.
|
||||
if (event.error === "browser.pane.registration.closed") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
setStore(id, { registration: undefined, browser: null, error: undefined })
|
||||
entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++))
|
||||
return
|
||||
}
|
||||
if (event.state) entry.attempts = 0
|
||||
batch(() => {
|
||||
setStore(id, "browser", reconcile(event.state))
|
||||
setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined)
|
||||
})
|
||||
})
|
||||
entry.registration = registration
|
||||
setStore(id, { registration, browser: null, error: undefined })
|
||||
}
|
||||
// A new session appears in the UI before its server-side creation finishes. The listener
|
||||
// belongs to this provider, not to the route effect that happened to call attach().
|
||||
const data = server.ctx.data
|
||||
const unsubscribe = runWithOwner(owner, () =>
|
||||
data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
}),
|
||||
)
|
||||
if (!data.session.creating(sessionID)) register()
|
||||
entry.dispose = () => {
|
||||
unsubscribe?.()
|
||||
clearTimeout(entry.retry)
|
||||
entry.registration?.close()
|
||||
}
|
||||
},
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
focus.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
const registration = live.get(key(server, sessionID))?.registration
|
||||
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
|
||||
return registration.command(command)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import type { SessionModel } from "../model"
|
||||
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
|
||||
import { useBrowserAttachments } from "./attachments"
|
||||
|
||||
export function createSessionBrowser(session: SessionModel) {
|
||||
const attachments = useBrowserAttachments()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [local, setLocal] = createStore({ error: undefined as string | undefined })
|
||||
const attachment = () => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
return sessionID ? attachments.state(server, sessionID) : undefined
|
||||
}
|
||||
const available = createMemo(
|
||||
() =>
|
||||
attachments.enabled() &&
|
||||
attachments.supported(server) &&
|
||||
!!session.identity.sessionID() &&
|
||||
!server.health?.incompatible &&
|
||||
session.isDesktop(),
|
||||
)
|
||||
const attached = () => attachment()?.registration !== undefined
|
||||
const browserTabs = createMemo(
|
||||
() =>
|
||||
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
|
||||
[],
|
||||
)
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
setLocal("error", undefined)
|
||||
const owner = session.ownership.capture()
|
||||
void attachments.command(server, sessionID, command).catch(() => {
|
||||
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().active(),
|
||||
(active) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
|
||||
if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().all(),
|
||||
(current, previous) => {
|
||||
previous
|
||||
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
|
||||
.forEach((key) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
|
||||
if (tab) command({ type: "tabs.close", tabID: tab.id })
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
// Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last
|
||||
// inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms.
|
||||
createEffect(
|
||||
on(
|
||||
() => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)),
|
||||
(ids, previous) => {
|
||||
if (!ids) return
|
||||
const known = new Set(previous ?? [])
|
||||
batch(() => {
|
||||
const tabs = session.layout.tabs()
|
||||
tabs
|
||||
.all()
|
||||
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
|
||||
.forEach(tabs.close)
|
||||
const current = tabs.all()
|
||||
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
|
||||
if (added.length) tabs.setAll([...current, ...added])
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
return {
|
||||
available,
|
||||
attached,
|
||||
opened: () => attached() && browserTabs().length > 0,
|
||||
state: () => attachment()?.browser ?? null,
|
||||
tabs: browserTabs,
|
||||
active: () =>
|
||||
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
|
||||
browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ??
|
||||
browserTabs()[0],
|
||||
error: () => local.error ?? attachment()?.error,
|
||||
registration: () => attachment()?.registration,
|
||||
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
|
||||
open: () => command({ type: "tabs.open" }),
|
||||
command,
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: {
|
||||
registration: BrowserPaneRegistration
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
visible: boolean
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const state = props.browser.active
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
const covered = (rect: DOMRect) =>
|
||||
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
if (!tab) {
|
||||
props.registration.setLayout()
|
||||
return
|
||||
}
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const zoom = platform.webviewZoom?.() ?? 1
|
||||
const left = Math.round(rect.left * zoom)
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
|
||||
).backgroundColor
|
||||
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
|
||||
if (next !== layout) {
|
||||
layout = next
|
||||
// Let the browser resolve the semantic backdrop color, including custom
|
||||
// themes using color formats that Electron's color parser cannot read.
|
||||
if (paint) {
|
||||
paint.clearRect(0, 0, 1, 1)
|
||||
paint.fillStyle = color
|
||||
paint.fillRect(0, 0, 1, 1)
|
||||
}
|
||||
const rgba = paint?.getImageData(0, 0, 1, 1).data
|
||||
props.registration.setLayout({
|
||||
tabID: tab.id,
|
||||
visible,
|
||||
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
||||
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
|
||||
radius: Math.round(10 * zoom),
|
||||
})
|
||||
}
|
||||
}
|
||||
const tick = () => {
|
||||
frame = undefined
|
||||
measure()
|
||||
if (performance.now() < until) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
const schedule = (duration = 0) => {
|
||||
until = Math.max(until, performance.now() + duration)
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
() => platform.webviewZoom?.(),
|
||||
() => dialog.active,
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
],
|
||||
() => schedule(300),
|
||||
),
|
||||
)
|
||||
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
|
||||
// animation frame keeps the native view in step with a pane drag.
|
||||
createResizeObserver(() => surface, measure)
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// Floating content portals directly into <body>; keep measuring briefly so
|
||||
// the positioner has settled before the overlap check runs.
|
||||
const portals = new MutationObserver(() => schedule(300))
|
||||
portals.observe(document.body, { childList: true })
|
||||
onCleanup(() => portals.disconnect())
|
||||
const appearance = new MutationObserver(() => schedule(300))
|
||||
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
|
||||
onCleanup(() => appearance.disconnect())
|
||||
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
props.registration.setLayout()
|
||||
})
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { Mark } from "@opencode/ui/logo"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
@@ -21,8 +22,6 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
|
||||
const reviewTabID = "session-side-panel-review-tab"
|
||||
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
||||
const browserTabID = "session-side-panel-browser-tab"
|
||||
const browserTabPanelID = "session-side-panel-browser-tabpanel"
|
||||
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
|
||||
import { SessionContextTab } from "@/session/files/session-context-tab"
|
||||
import { SortableTab } from "@/session/files/tab"
|
||||
@@ -36,8 +35,6 @@ import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
shouldShowFileTree,
|
||||
@@ -46,8 +43,9 @@ import {
|
||||
import { setSessionHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
|
||||
import { SessionBrowserPane } from "@/session/browser/pane"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
import type { SessionExtensions } from "@/extensions/session"
|
||||
import { isExtensionTab } from "@/extensions/keys"
|
||||
import { ExtensionPanelContent } from "@/extensions/content"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
@@ -58,6 +56,7 @@ function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
extensions: SessionExtensions
|
||||
canReview: boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
@@ -72,7 +71,6 @@ export function SessionSidePanel(props: {
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -81,6 +79,7 @@ export function SessionSidePanel(props: {
|
||||
const command = useCommand()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
const extensions = props.extensions
|
||||
const projectDirectory = createMemo(() => sdk().directory)
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -173,7 +172,9 @@ export function SessionSidePanel(props: {
|
||||
review: reviewTab,
|
||||
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 +226,12 @@ 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" &&
|
||||
!extensions.keys().includes(active)
|
||||
)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
@@ -365,21 +371,22 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
<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={() => props.browser.close(item().id)}
|
||||
id={`${browserTabID}-${item().id}`}
|
||||
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
|
||||
onTabClose={panel().props.closable === false ? undefined : tabs().close}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{item().title || language.t("session.tab.browser")}
|
||||
<Show when={panel().props.loading} fallback={panel().icon()}>
|
||||
<Loader />
|
||||
</Show>
|
||||
<span class="max-w-40 truncate" dir="auto">
|
||||
{panel().props.title}
|
||||
</span>
|
||||
<Show when={panel().props.badge}>{panel().props.badge}</Show>
|
||||
</div>
|
||||
</SortableTab>
|
||||
)}
|
||||
@@ -436,7 +443,7 @@ export function SessionSidePanel(props: {
|
||||
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{/* With only files to add, the plus stays a one-click "Open file" button. */}
|
||||
<Show
|
||||
when={props.browser.available()}
|
||||
when={extensions.hasActions()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
value={
|
||||
@@ -491,12 +498,7 @@ export function SessionSidePanel(props: {
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={props.browser.open}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
{extensions.actions()}
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
@@ -552,29 +554,7 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={
|
||||
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
|
||||
}
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
|
||||
inert={!isSessionBrowserTab(activeTab()) || undefined}
|
||||
>
|
||||
<Show when={props.browser.registration()} keyed>
|
||||
{(registration) => (
|
||||
<SessionBrowserPane
|
||||
registration={registration}
|
||||
browser={props.browser}
|
||||
visible={isSessionBrowserTab(activeTab())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<ExtensionPanelContent panels={extensions.panels()} active={activeTab()} />
|
||||
|
||||
<Show when={fileBrowserMounted()}>
|
||||
<div
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
SESSION_BROWSER_TAB,
|
||||
sessionBrowserTab,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
createOpenReviewFile,
|
||||
createOpenSessionFileTab,
|
||||
@@ -12,6 +10,7 @@ import {
|
||||
getTabReorderIndex,
|
||||
shouldShowFileTree,
|
||||
} from "./helpers"
|
||||
import { extensionTabKey } from "@/extensions/keys"
|
||||
|
||||
describe("shouldShowFileTree", () => {
|
||||
test("does not reserve space for a disabled file tree", () => {
|
||||
@@ -236,35 +235,36 @@ describe("createSessionTabs", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes one browser tab without treating it as a file tab", () => {
|
||||
test("exposes one extension panel without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
|
||||
const panel = extensionTabKey("example", "panel")
|
||||
const tabs = createMemo(() => ({ active: () => panel, all: () => [panel] }))
|
||||
const result = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: () => undefined,
|
||||
normalizeTab: (tab) => tab,
|
||||
browser: () => true,
|
||||
extensions: () => [panel],
|
||||
})
|
||||
|
||||
expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB])
|
||||
expect(result.panelTabs()).toEqual([panel])
|
||||
expect(result.openedTabs()).toEqual([])
|
||||
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
|
||||
expect(result.activeTab()).toBe(panel)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
|
||||
expect(result.closableTab()).toBe(panel)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => {
|
||||
const first = sessionBrowserTab("first")
|
||||
const second = sessionBrowserTab("second")
|
||||
test("keeps extension panels in layout order beside files and drops unavailable contributions", () => {
|
||||
const first = extensionTabKey("example", "first")
|
||||
const second = extensionTabKey("example", "second")
|
||||
const input = {
|
||||
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
|
||||
pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
|
||||
normalizeTab: (tab: string) => tab,
|
||||
}
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => true })
|
||||
const result = createSessionTabs({ ...input, extensions: () => [first, second] })
|
||||
expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second])
|
||||
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe(second)
|
||||
@@ -273,7 +273,7 @@ describe("createSessionTabs", () => {
|
||||
dispose()
|
||||
})
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => false })
|
||||
const result = createSessionTabs({ ...input, extensions: () => [] })
|
||||
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe("file://src/a.ts")
|
||||
expect(result.closableTab()).toBe("file://src/a.ts")
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createSizing } from "@opencode/ui/resize-state"
|
||||
export { createSizing }
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { isExtensionTab } from "@/extensions/keys"
|
||||
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
sessionBrowserTab,
|
||||
isSessionBrowserTab,
|
||||
} from "@/shell/state/session-tabs"
|
||||
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
|
||||
const emptyTabs: string[] = []
|
||||
|
||||
@@ -25,7 +21,9 @@ type TabsInput = {
|
||||
review?: Accessor<boolean>
|
||||
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 }) {
|
||||
@@ -36,7 +34,6 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const review = input.review ?? (() => false)
|
||||
const hasReview = input.hasReview ?? (() => false)
|
||||
const fileBrowser = input.fileBrowser ?? (() => false)
|
||||
const browser = input.browser ?? (() => false)
|
||||
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
||||
const openFileOpen = createMemo(
|
||||
() =>
|
||||
@@ -50,8 +47,10 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (input.extensions?.().includes(tab)) return [tab]
|
||||
if (tab === "context" || tab === "review") return []
|
||||
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
|
||||
if (isExtensionTab(tab)) return input.extensions?.().includes(tab) ? [tab] : []
|
||||
if (tab !== SESSION_OPEN_FILE_TAB && !input.pathFromTab(tab)) return []
|
||||
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
if (seen.has(value)) return []
|
||||
@@ -63,20 +62,33 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
input
|
||||
.tabs()
|
||||
.all()
|
||||
.filter((tab) => !!input.pathFromTab(tab))
|
||||
.map(input.normalizeTab),
|
||||
),
|
||||
),
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
{
|
||||
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
|
||||
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
||||
|
||||
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,9 +100,9 @@ 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
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
@@ -172,44 +184,4 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
|
||||
return toIndex
|
||||
}
|
||||
|
||||
export const createSizing = () => {
|
||||
const [state, setState] = createStore({ active: false })
|
||||
let t: number | undefined
|
||||
|
||||
const stop = () => {
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t)
|
||||
t = undefined
|
||||
}
|
||||
setState("active", false)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t)
|
||||
t = undefined
|
||||
}
|
||||
setState("active", true)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pointerup", stop)
|
||||
makeEventListener(window, "pointercancel", stop)
|
||||
makeEventListener(window, "blur", stop)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (t !== undefined) clearTimeout(t)
|
||||
})
|
||||
|
||||
return {
|
||||
active: () => state.active,
|
||||
start,
|
||||
touch() {
|
||||
start()
|
||||
t = window.setTimeout(stop, 120)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Sizing = ReturnType<typeof createSizing>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useData } from "@/runtime/server/current"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useBrowserAttachments } from "./browser/attachments"
|
||||
import { createSessionTabs } from "./helpers"
|
||||
import {
|
||||
normalizeSessionTab,
|
||||
@@ -19,6 +18,7 @@ import { useSessionLayout } from "./session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useOptionalDesktopExtensions } from "@/extensions/provider"
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
const emptyUserMessages: SessionMessageUser[] = []
|
||||
@@ -29,7 +29,7 @@ export function useSessionModel() {
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const shellTabs = useTabs()
|
||||
const attachments = useBrowserAttachments()
|
||||
const extensions = useOptionalDesktopExtensions()
|
||||
const layout = useSessionLayout()
|
||||
const location = useWorkspaceLocation()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -83,12 +83,21 @@ 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: () => {
|
||||
const id = sessionID()
|
||||
return !!id && attachments.state(server, id)?.registration !== undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SessionSidePanel } from "../files/session-side-panel"
|
||||
import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
import type { SessionExtensions } from "@/extensions/session"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
@@ -147,12 +147,13 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
extensions: SessionExtensions
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
extensions={props.extensions}
|
||||
canReview={props.review.canReview()}
|
||||
diffs={props.review.diffs()}
|
||||
diffsReady={props.review.ready()}
|
||||
@@ -173,7 +174,6 @@ export function SessionDesktopReview(props: {
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -33,7 +33,8 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { useExtensionPanels } from "@/extensions/session"
|
||||
import { createSessionServices } from "@/extensions/workspace"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -48,7 +49,14 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const extensions = useExtensionPanels({
|
||||
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 +257,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 +297,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 +389,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview review={review} extensions={extensions} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,76 +1,6 @@
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { AuxiliaryPanel } from "@opencode/ui/auxiliary-panel"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
|
||||
export function TerminalSurface(
|
||||
props: ParentProps<{
|
||||
label: string
|
||||
opened: boolean
|
||||
present?: boolean
|
||||
framed?: boolean
|
||||
embedded?: boolean
|
||||
desktop: boolean
|
||||
stacked: boolean
|
||||
height: string
|
||||
contentHeight: string
|
||||
pane: number
|
||||
max: number
|
||||
resizing: boolean
|
||||
animate?: boolean
|
||||
onResizeStart: () => void
|
||||
onResize: (height: number) => void
|
||||
onCollapse: () => void
|
||||
ref?: (element: HTMLElement) => void
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<aside
|
||||
ref={props.ref}
|
||||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={
|
||||
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
|
||||
}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
inert={!props.opened}
|
||||
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"w-full": !props.desktop || props.stacked,
|
||||
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
|
||||
"will-change-[height]": !props.embedded && !props.resizing && (!props.desktop || props.stacked),
|
||||
}}
|
||||
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
|
||||
>
|
||||
<div
|
||||
classList={{ "md:hidden": !props.stacked, hidden: props.stacked || props.embedded }}
|
||||
onPointerDown={props.onResizeStart}
|
||||
>
|
||||
<ResizeHandle
|
||||
class="-top-1"
|
||||
direction="vertical"
|
||||
size={props.pane}
|
||||
min={100}
|
||||
max={props.max}
|
||||
collapseThreshold={50}
|
||||
onResize={props.onResize}
|
||||
onCollapse={props.onCollapse}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="terminal-panel-content"
|
||||
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop && !props.embedded,
|
||||
"pointer-events-none": !props.opened,
|
||||
}}
|
||||
style={{ height: props.contentHeight }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
export function TerminalSurface(props: ComponentProps<typeof AuxiliaryPanel>) {
|
||||
return <AuxiliaryPanel {...props} id="terminal-panel" data-component="terminal-panel" />
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Component, Show } from "solid-js"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import "@/settings/settings.css"
|
||||
import { ExtensionSlot } from "@/extensions/slot"
|
||||
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
|
||||
export const SettingsExperimental: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -31,22 +30,7 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<ExtensionSlot path="settings.experimental" />
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
.desktop-extension-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
.desktop-extension-install {
|
||||
gap: 14px;
|
||||
}
|
||||
.desktop-extension-drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
color: var(--v2-text-text-muted);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
transition: 120ms ease-out;
|
||||
}
|
||||
.desktop-extension-drop[data-dragging="true"] {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
color: var(--v2-text-text-base);
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
|
||||
}
|
||||
.desktop-extension-drop > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.desktop-extension-drop strong {
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
font-weight: 590;
|
||||
}
|
||||
.desktop-extension-drop span,
|
||||
.desktop-extension-empty {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
.desktop-extension-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.desktop-extension-url [data-component="text-input-v2"] {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.desktop-extension-method-label {
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.desktop-extension-error {
|
||||
color: var(--v2-state-fg-danger);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
.desktop-extension-title {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.desktop-extension-empty {
|
||||
padding: 20px;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { SettingsRow } from "@opencode/ui/layout"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import { useDesktopExtensions } from "@/extensions/provider"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import "./extensions.css"
|
||||
|
||||
const errors = {
|
||||
invalidArchive: "settings.desktopExtensions.error.archive",
|
||||
invalidManifest: "settings.desktopExtensions.error.manifest",
|
||||
invalidPath: "settings.desktopExtensions.error.path",
|
||||
tooLarge: "settings.desktopExtensions.error.size",
|
||||
reserved: "settings.desktopExtensions.error.reserved",
|
||||
notFound: "settings.desktopExtensions.error.notFound",
|
||||
disabled: "settings.desktopExtensions.error.disabled",
|
||||
invalidModule: "settings.desktopExtensions.error.module",
|
||||
download: "settings.desktopExtensions.error.download",
|
||||
url: "settings.desktopExtensions.error.url",
|
||||
storage: "settings.desktopExtensions.error.storage",
|
||||
files: "settings.desktopExtensions.error.files",
|
||||
} as const
|
||||
|
||||
/** Port of OCDX's manager, backed by the native Desktop extension registry. */
|
||||
export function SettingsExtensions() {
|
||||
const host = useDesktopExtensions()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({
|
||||
url: "",
|
||||
busy: false,
|
||||
dragging: false,
|
||||
error: undefined as keyof typeof errors | undefined,
|
||||
})
|
||||
let picker: HTMLInputElement | undefined
|
||||
const entries = createMemo(() =>
|
||||
[
|
||||
...host.builtins().map((plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name ? (language.t(plugin.name as Parameters<typeof language.t>[0]) ?? plugin.name) : plugin.id,
|
||||
version: plugin.version ?? platform.version ?? "0.0.0",
|
||||
enabled: true,
|
||||
builtin: true,
|
||||
hasMain: plugin.main ?? false,
|
||||
})),
|
||||
...host.state.installed
|
||||
.filter((entry) => !host.builtins().some((plugin) => plugin.id === entry.id))
|
||||
.map((entry) => ({ ...entry, builtin: false })),
|
||||
].toSorted((a, b) => Number(b.builtin) - Number(a.builtin) || a.name.localeCompare(b.name)),
|
||||
)
|
||||
const error = () => state.error ?? host.state.managerError
|
||||
const failed = () => host.state.installed.find((entry) => host.state.failures[entry.id])
|
||||
const perform = async (action: (manager: ExtensionManager.Transport) => Promise<unknown>) => {
|
||||
if (state.busy || !host.manager) return false
|
||||
setState({ busy: true, error: undefined })
|
||||
try {
|
||||
await action(host.manager)
|
||||
return true
|
||||
} catch (error) {
|
||||
setState("error", error instanceof ExtensionManager.ManagerError ? error.code : "storage")
|
||||
return false
|
||||
} finally {
|
||||
setState("busy", false)
|
||||
}
|
||||
}
|
||||
const installFiles = async (files: FileList | File[]) => {
|
||||
const archives = Array.from(files).filter((file) => file.name.toLowerCase().endsWith(".ocdx"))
|
||||
if (!archives.length) {
|
||||
setState("error", "files")
|
||||
return
|
||||
}
|
||||
await perform(async (manager) => {
|
||||
for (const file of archives) await manager.install(new Uint8Array(await file.arrayBuffer()))
|
||||
})
|
||||
}
|
||||
const installURL = async () => {
|
||||
const url = state.url.trim()
|
||||
if (!url) return
|
||||
if (await perform((manager) => manager.installURL(url))) setState("url", "")
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body desktop-extension-manager" data-component="desktop-extension-manager">
|
||||
<section class="settings-section desktop-extension-install">
|
||||
<h3 class="settings-section-title">{language.t("settings.desktopExtensions.installTitle")}</h3>
|
||||
<div
|
||||
class="desktop-extension-drop"
|
||||
data-dragging={state.dragging}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
setState("dragging", true)
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return
|
||||
setState("dragging", false)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setState("dragging", false)
|
||||
if (event.dataTransfer) void installFiles(event.dataTransfer.files)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{language.t("settings.desktopExtensions.drop")}</strong>
|
||||
<span>{language.t("settings.desktopExtensions.choose")}</span>
|
||||
</div>
|
||||
<Button size="small" variant="neutral" disabled={state.busy} onClick={() => picker?.click()}>
|
||||
{language.t("settings.desktopExtensions.browse")}
|
||||
</Button>
|
||||
<input
|
||||
ref={picker}
|
||||
type="file"
|
||||
accept=".ocdx"
|
||||
multiple
|
||||
hidden
|
||||
aria-label={language.t("settings.desktopExtensions.files")}
|
||||
onChange={(event) => {
|
||||
if (event.currentTarget.files) void installFiles(event.currentTarget.files)
|
||||
event.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="desktop-extension-method-label">{language.t("settings.desktopExtensions.fromURL")}</div>
|
||||
<div class="desktop-extension-url">
|
||||
<TextInput
|
||||
appearance="large"
|
||||
dir="ltr"
|
||||
value={state.url}
|
||||
placeholder={language.t("settings.desktopExtensions.placeholder")}
|
||||
aria-label={language.t("settings.desktopExtensions.url")}
|
||||
disabled={state.busy}
|
||||
onInput={(event) => setState("url", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void installURL()
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
disabled={state.busy || !state.url.trim()}
|
||||
onClick={() => void installURL()}
|
||||
>
|
||||
{language.t("settings.desktopExtensions.install")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<Show when={error()}>
|
||||
{(error) => (
|
||||
<div role="alert" class="desktop-extension-error">
|
||||
{language.t(errors[error()])}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={failed()}>
|
||||
{(entry) => (
|
||||
<div role="alert" class="desktop-extension-error">
|
||||
{language.t("settings.desktopExtensions.error.activation", { name: entry().name })}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.desktopExtensions.installed")}</h3>
|
||||
<div data-component="settings-list">
|
||||
<Show
|
||||
when={host.state.managerReady}
|
||||
fallback={
|
||||
<div role="status" class="desktop-extension-empty">
|
||||
{language.t("common.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={entries().length}
|
||||
fallback={<div class="desktop-extension-empty">{language.t("settings.desktopExtensions.empty")}</div>}
|
||||
>
|
||||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<SettingsRow
|
||||
title={
|
||||
<span class="desktop-extension-title">
|
||||
<bdi dir="auto">{entry.name}</bdi>
|
||||
<Badge variant="neutral">v{entry.version}</Badge>
|
||||
<Show when={entry.builtin}>
|
||||
<Badge variant="accent">{language.t("settings.desktopExtensions.builtin")}</Badge>
|
||||
</Show>
|
||||
<Show when={entry.hasMain}>
|
||||
<Badge variant="neutral">{language.t("settings.desktopExtensions.main")}</Badge>
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
description={<bdi dir="ltr">{entry.id}</bdi>}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Show when={!entry.builtin}>
|
||||
<Tooltip value={language.t("settings.desktopExtensions.reload", { name: entry.name })}>
|
||||
<IconButton
|
||||
icon={<Icon name="reset" />}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
disabled={state.busy || !entry.enabled}
|
||||
aria-label={language.t("settings.desktopExtensions.reload", { name: entry.name })}
|
||||
onClick={() => void perform((manager) => manager.reload(entry.id))}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Switch
|
||||
checked={entry.enabled}
|
||||
disabled={entry.builtin || state.busy}
|
||||
onChange={(enabled) => void perform((manager) => manager.enable(entry.id, enabled))}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.desktopExtensions.enable", { name: entry.name })}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -20,7 +20,7 @@ interface PluginRowItem {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SettingsExtensions: Component = () => {
|
||||
export const SettingsTools: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const data = useData()
|
||||
@@ -60,8 +60,8 @@ export const SettingsExtensions: Component = () => {
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.tools")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.tools.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
@@ -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"
|
||||
|
||||
@@ -15,7 +15,9 @@ import { SettingsModels } from "./models/models"
|
||||
import { SettingsServers } from "./servers/servers"
|
||||
import { SettingsWorkspaces } from "./workspaces/workspaces"
|
||||
import { SettingsProjects } from "./workspaces/projects"
|
||||
import { SettingsExtensions } from "./providers/extensions"
|
||||
import { SettingsTools } from "./providers/tools"
|
||||
import { SettingsExtensions } from "./extensions/extensions"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsAbout } from "./about/about"
|
||||
import { SettingsServerScope } from "./server-scope"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
@@ -41,14 +43,21 @@ const sections = [
|
||||
[
|
||||
{ value: "providers", icon: "providers", label: "settings.providers.title" },
|
||||
{ value: "models", icon: "models", label: "settings.models.title" },
|
||||
{ value: "tools", icon: "extensions", label: "settings.tab.tools" },
|
||||
],
|
||||
[
|
||||
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
|
||||
{ value: "experimental", icon: "flask", label: "settings.tab.experimental" },
|
||||
],
|
||||
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
|
||||
[{ value: "about", icon: "info", label: "settings.tab.about" }],
|
||||
] as const
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const visibleSections = createMemo(() =>
|
||||
sections.map((group) => group.filter((section) => section.value !== "extensions" || !!platform.extensionManager)),
|
||||
)
|
||||
const dialog = useDialog()
|
||||
const surface = useSettingsSurface()
|
||||
const layout = useLayout()
|
||||
@@ -56,6 +65,9 @@ export const SettingsScreen: Component = () => {
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
createEffect(() => {
|
||||
if (!platform.extensionManager && surface.tab() === "extensions") surface.open("tools")
|
||||
})
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
@@ -128,8 +140,9 @@ export const SettingsScreen: Component = () => {
|
||||
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
|
||||
<span>
|
||||
{language.t(
|
||||
sections.flat().find((section) => section.value === surface.tab())?.label ??
|
||||
"settings.tab.preferences",
|
||||
visibleSections()
|
||||
.flat()
|
||||
.find((section) => section.value === surface.tab())?.label ?? "settings.tab.preferences",
|
||||
)}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
@@ -140,7 +153,7 @@ export const SettingsScreen: Component = () => {
|
||||
value={surface.tab()}
|
||||
onChange={(value) => void startTransition(() => surface.open(value))}
|
||||
>
|
||||
<For each={sections}>
|
||||
<For each={visibleSections()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
@@ -176,9 +189,9 @@ export const SettingsScreen: Component = () => {
|
||||
<span>{language.t("settings.backToApp")}</span>
|
||||
</button>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<For each={sections}>
|
||||
<For each={visibleSections()}>
|
||||
{(group) => (
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<div data-slot="settings-nav-group" class="flex flex-col gap-1 w-full">
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Tabs.Trigger
|
||||
@@ -214,6 +227,11 @@ export const SettingsScreen: Component = () => {
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
<Show when={platform.extensionManager}>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions />
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<Tabs.Content value="servers" class="settings-panel">
|
||||
<SettingsServers />
|
||||
</Tabs.Content>
|
||||
@@ -222,10 +240,7 @@ export const SettingsScreen: Component = () => {
|
||||
</Tabs.Content>
|
||||
<SettingsServerScope directory={directory()}>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces
|
||||
activeDirectory={directory()}
|
||||
resetProjectFilter={() => state.worktreeFilterReset}
|
||||
/>
|
||||
<SettingsWorkspaces activeDirectory={directory()} resetProjectFilter={() => state.worktreeFilterReset} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={directory()} onBack={showProviders} />
|
||||
@@ -233,8 +248,8 @@ export const SettingsScreen: Component = () => {
|
||||
<Tabs.Content value="models" class="settings-panel">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions />
|
||||
<Tabs.Content value="tools" class="settings-panel">
|
||||
<SettingsTools />
|
||||
</Tabs.Content>
|
||||
</SettingsServerScope>
|
||||
<Tabs.Content value="about" class="settings-panel settings-about">
|
||||
|
||||
@@ -67,7 +67,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="extensions">
|
||||
<Icon name="extensions" size="small" />
|
||||
{language.t("settings.tab.extensions")}
|
||||
{language.t("settings.tab.tools")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -147,8 +147,8 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
return (
|
||||
<div class="project-settings-extensions">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("settings.tab.extensions")}</h2>
|
||||
<span>{language.t("project.settings.extensions.description")}</span>
|
||||
<h2>{language.t("settings.tab.tools")}</h2>
|
||||
<span>{language.t("project.settings.tools.description")}</span>
|
||||
</div>
|
||||
|
||||
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
|
||||
|
||||
@@ -458,6 +458,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const config = keybindConfig(id)
|
||||
return config ? formatKeybindParts(config, language.t) : []
|
||||
},
|
||||
matches(id: string, event: KeyboardEvent) {
|
||||
const config = keybindConfig(id)
|
||||
return !!config && matchKeybind(parseKeybind(config), event)
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
|
||||
import { DesktopExtensionsProvider } from "@/extensions/provider"
|
||||
import { ExtensionSlot } from "@/extensions/slot"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
@@ -79,9 +80,11 @@ function AppLayout(props: ParentProps) {
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
<DesktopExtensionsProvider>
|
||||
<ExtensionSlot path="app">
|
||||
<Shell>{props.children}</Shell>
|
||||
</ExtensionSlot>
|
||||
</DesktopExtensionsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
</Show>
|
||||
|
||||
@@ -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,8 +1,4 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
export const SESSION_BROWSER_TAB = "browser"
|
||||
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
|
||||
export const isSessionBrowserTab = (tab: string | undefined) =>
|
||||
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
|
||||
|
||||
export type SessionTabs = {
|
||||
active?: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { ExtensionSlot } from "@/extensions/slot"
|
||||
|
||||
type Registration = {
|
||||
active: () => boolean
|
||||
@@ -45,7 +46,9 @@ export function TitlebarRightMount(props: { vertical?: boolean }) {
|
||||
ref={slot.setMount}
|
||||
id="opencode-titlebar-right"
|
||||
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
|
||||
/>
|
||||
>
|
||||
<ExtensionSlot path="titlebar.actions" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -48,6 +48,40 @@ function desktop() {
|
||||
}
|
||||
|
||||
describe("schema-backed persistence", () => {
|
||||
for (const mode of ["web", "desktop"] as const) {
|
||||
test(`relocates an extracted workspace key from an alias in ${mode} storage`, async () => {
|
||||
const native = desktop()
|
||||
const target = {
|
||||
...Persist.workspace(`C:\\extension-relocation-${mode}`, "extension.example.document"),
|
||||
previousKeys: ["workspace:legacy"],
|
||||
}
|
||||
const previous = `${target.workspaceStorageAliases![0]}:workspace:legacy`
|
||||
const current = `${target.storage}:${target.key}`
|
||||
const read = (key: string) => (mode === "web" ? localStorage.getItem(key) : native.values.get(key))
|
||||
const raw = JSON.stringify({ enabled: false, label: "retained" })
|
||||
if (mode === "web") localStorage.setItem(previous, raw)
|
||||
if (mode === "desktop") native.values.set(previous, raw)
|
||||
const mounted = createRoot((dispose) => {
|
||||
const state = persisted(target, Current, initial, mode === "web" ? web : native.platform)
|
||||
const ready = new Promise<void>((resolve) =>
|
||||
createComputed(() => {
|
||||
if (state[3]()) resolve()
|
||||
}),
|
||||
)
|
||||
return { dispose, state, ready }
|
||||
})
|
||||
try {
|
||||
await mounted.ready
|
||||
expect(mounted.state[3]()).toBe(true)
|
||||
expect(mounted.state[0]).toEqual({ enabled: false, label: "retained" })
|
||||
expect(read(current)).toBe(raw)
|
||||
expect(read(previous)).toBeFalsy()
|
||||
} finally {
|
||||
mounted.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("clears the recent tab after restoring it from storage", () => {
|
||||
const target = Persist.global("schema-recent-clear")
|
||||
const key = `${target.storage}:${target.key}`
|
||||
|
||||
@@ -38,8 +38,10 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/plugin-browser-desktop": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Uint8ArrayReader, Uint8ArrayWriter, TextWriter, ZipReader } from "@zip.js/zip.js"
|
||||
import { Schema } from "effect"
|
||||
import { createHash } from "node:crypto"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
|
||||
export const archiveLimit = 1_073_741_824
|
||||
|
||||
export function archivePath(value: string) {
|
||||
if (
|
||||
!value ||
|
||||
value.includes("\\") ||
|
||||
value.includes(":") ||
|
||||
/[\u0000-\u001f]/.test(value) ||
|
||||
value.startsWith("/") ||
|
||||
value.split("/").some((part) => !part || part === "." || part === "..")
|
||||
) {
|
||||
throw new ExtensionManager.ManagerError("invalidPath")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export async function readArchive(data: Uint8Array) {
|
||||
if (data.byteLength > archiveLimit) throw new ExtensionManager.ManagerError("tooLarge")
|
||||
if (data[0] !== 0x50 || data[1] !== 0x4b) throw new ExtensionManager.ManagerError("invalidArchive")
|
||||
// zip.js expects slice() to copy; Node Buffer.slice() returns a view instead.
|
||||
const reader = new ZipReader(new Uint8ArrayReader(Buffer.isBuffer(data) ? new Uint8Array(data) : data), {
|
||||
useWebWorkers: false,
|
||||
})
|
||||
try {
|
||||
const entries = await reader.getEntries()
|
||||
if (entries.length > 1024 || entries.reduce((total, entry) => total + entry.uncompressedSize, 0) > archiveLimit)
|
||||
throw new ExtensionManager.ManagerError("tooLarge")
|
||||
const paths = entries.map((entry) =>
|
||||
archivePath(entry.directory ? entry.filename.replace(/\/$/, "") : entry.filename),
|
||||
)
|
||||
if (new Set(paths).size !== paths.length) throw new ExtensionManager.ManagerError("invalidPath")
|
||||
const metadata = entries.find((entry) => entry.filename === "manifest.json" && !entry.directory)
|
||||
if (!metadata || metadata.directory || !metadata.getData || metadata.uncompressedSize > 65536)
|
||||
throw new ExtensionManager.ManagerError("invalidManifest")
|
||||
const manifest = Schema.decodeUnknownOption(Schema.fromJsonString(ExtensionManager.Manifest))(
|
||||
await metadata.getData(new TextWriter()),
|
||||
)
|
||||
if (manifest._tag === "None") throw new ExtensionManager.ManagerError("invalidManifest")
|
||||
const value = manifest.value
|
||||
archivePath(value.entry)
|
||||
if (value.main) archivePath(value.main)
|
||||
if (value.style) archivePath(value.style)
|
||||
const files = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.directory)
|
||||
.map(async (entry) => {
|
||||
if (!entry.getData) throw new ExtensionManager.ManagerError("invalidArchive")
|
||||
return { path: entry.filename, data: Buffer.from(await entry.getData(new Uint8ArrayWriter())) }
|
||||
}),
|
||||
)
|
||||
if (files.reduce((total, file) => total + file.data.byteLength, 0) > archiveLimit)
|
||||
throw new ExtensionManager.ManagerError("tooLarge")
|
||||
if (value.style && !files.some((file) => file.path === value.style))
|
||||
throw new ExtensionManager.ManagerError("invalidManifest")
|
||||
for (const entry of [value.entry, value.main].filter((entry) => entry !== undefined)) {
|
||||
const file = files.find((file) => file.path === entry)
|
||||
if (!file) throw new ExtensionManager.ManagerError("invalidManifest")
|
||||
// Reject syntax errors before replacing a working installation. Execution
|
||||
// remains in the renderer/main host with their shared module identities.
|
||||
try {
|
||||
new Function("require", "module", "exports", file.data.toString("utf8"))
|
||||
} catch {
|
||||
throw new ExtensionManager.ManagerError("invalidModule")
|
||||
}
|
||||
}
|
||||
return { manifest: value, revision: createHash("sha256").update(data).digest("hex"), files }
|
||||
} catch (error) {
|
||||
if (error instanceof ExtensionManager.ManagerError) throw error
|
||||
throw new ExtensionManager.ManagerError("invalidArchive", { cause: error })
|
||||
} finally {
|
||||
await reader.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import type { Database } from "../storage/database"
|
||||
import { extensions, extensionFiles } from "../storage/schema"
|
||||
|
||||
const types: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
woff2: "font/woff2",
|
||||
woff: "font/woff",
|
||||
json: "application/json",
|
||||
css: "text/css",
|
||||
txt: "text/plain",
|
||||
pdf: "application/pdf",
|
||||
wasm: "application/wasm",
|
||||
}
|
||||
|
||||
export function extensionAssetResponse(
|
||||
db: Database,
|
||||
input: { id: string; revision: string; path: string; range?: string | null; head?: boolean },
|
||||
) {
|
||||
const where = and(
|
||||
eq(extensionFiles.extension_id, input.id),
|
||||
eq(extensionFiles.path, input.path),
|
||||
eq(extensions.revision, input.revision),
|
||||
eq(extensions.enabled, true),
|
||||
)
|
||||
const metadata = db
|
||||
.select({ size: sql<number>`length(${extensionFiles.data})` })
|
||||
.from(extensionFiles)
|
||||
.innerJoin(extensions, eq(extensionFiles.extension_id, extensions.id))
|
||||
.where(where)
|
||||
.get()
|
||||
if (!metadata) return new Response(null, { status: 404 })
|
||||
const headers = new Headers({
|
||||
"Content-Type": types[input.path.split(".").at(-1)?.toLowerCase() ?? ""] ?? "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
"Accept-Ranges": "bytes",
|
||||
})
|
||||
const match = input.range?.match(/^bytes=(\d*)-(\d*)$/)
|
||||
const start = match?.[1] ? Number(match[1]) : match?.[2] ? Math.max(0, metadata.size - Number(match[2])) : 0
|
||||
const end = match?.[1] && match[2] ? Math.min(Number(match[2]), metadata.size - 1) : metadata.size - 1
|
||||
if (
|
||||
input.range &&
|
||||
(!match ||
|
||||
(!match[1] && !match[2]) ||
|
||||
!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
start > end ||
|
||||
start >= metadata.size)
|
||||
) {
|
||||
headers.set("Content-Range", `bytes */${metadata.size}`)
|
||||
return new Response(null, { status: 416, headers })
|
||||
}
|
||||
headers.set("Content-Length", String(Math.max(0, end - start + 1)))
|
||||
if (input.range) headers.set("Content-Range", `bytes ${start}-${end}/${metadata.size}`)
|
||||
const data = input.head
|
||||
? undefined
|
||||
: db
|
||||
.select({
|
||||
data: sql<Uint8Array>`substr(${extensionFiles.data}, ${start + 1}, ${Math.max(0, end - start + 1)})`,
|
||||
})
|
||||
.from(extensionFiles)
|
||||
.innerJoin(extensions, eq(extensionFiles.extension_id, extensions.id))
|
||||
.where(where)
|
||||
.get()?.data
|
||||
return new Response(data ? new Uint8Array(data) : null, { status: input.range ? 206 : 200, headers })
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import BrowserExtension from "@opencode/plugin-browser-desktop/main"
|
||||
|
||||
export const mainExtensions: readonly MainPlugin.Entry[] = [BrowserExtension]
|
||||
@@ -0,0 +1,182 @@
|
||||
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,
|
||||
load?: (id: string) => Promise<MainPlugin.Entry | undefined>,
|
||||
) {
|
||||
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)
|
||||
},
|
||||
releaseAll(extensionID: string) {
|
||||
windows.forEach((host) => host.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) => {
|
||||
calls.forEach((call, key) => {
|
||||
if (key.startsWith(`${extensionID}/`)) call.abort()
|
||||
})
|
||||
const instance = instances.get(extensionID)
|
||||
instances.delete(extensionID)
|
||||
try {
|
||||
instance?.lifecycle.dispose()
|
||||
} finally {
|
||||
surfaces.release(extensionID)
|
||||
}
|
||||
}
|
||||
const instance = async (id: string) => {
|
||||
const previous = instances.get(id)
|
||||
if (previous) return previous
|
||||
const definition = plugins.find((plugin) => plugin.id === id) ?? (await load?.(id))
|
||||
if (!definition) throw new CallError("rpc.unavailable", `Desktop extension unavailable: ${id}`)
|
||||
const loaded = instances.get(id)
|
||||
if (loaded) return loaded
|
||||
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 = await instance(input.extensionID)
|
||||
controller.signal.throwIfAborted()
|
||||
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,97 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { openDatabase } from "../storage/database"
|
||||
import { createExtensionManager, readExtensionAsset } from "./manager"
|
||||
import { readArchive } from "./archive"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import { extensionArchive } from "../../../test/extensions/fixture"
|
||||
import { extensionAssetResponse } from "./assets"
|
||||
|
||||
test("installation, replacement, enable state and same-build reload persist in SQLite", async () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const changed: string[] = []
|
||||
const manager = createExtensionManager({ db: database.db, fetch, changed: (id) => changed.push(id) })
|
||||
const archive = await extensionArchive({ files: { "assets/value.txt": "one" } })
|
||||
const [first] = await manager.install(archive)
|
||||
expect(first).toMatchObject({ id: "test.lifecycle", name: "File utilities", enabled: true, generation: 1 })
|
||||
expect(readExtensionAsset(database.db, first.id, first.revision, "assets/value.txt")?.toString()).toBe("one")
|
||||
const [reloaded] = manager.reload(first.id)
|
||||
expect(reloaded).toMatchObject({ revision: first.revision, generation: 2 })
|
||||
manager.enable(first.id, false)
|
||||
expect(() => manager.source(first.id, first.revision)).toThrow("disabled")
|
||||
const restored = createExtensionManager({ db: database.db, fetch, changed() {} })
|
||||
expect(restored.list()[0].enabled).toBe(false)
|
||||
expect(readExtensionAsset(database.db, first.id, first.revision, "assets/value.txt")).toBeUndefined()
|
||||
manager.enable(first.id, true)
|
||||
const [updated] = await manager.install(
|
||||
await extensionArchive({ version: "2.0.0", files: { "assets/value.txt": "two" } }),
|
||||
)
|
||||
expect(updated).toMatchObject({ version: "2.0.0", generation: 5 })
|
||||
expect(updated.revision).not.toBe(first.revision)
|
||||
expect(readExtensionAsset(database.db, first.id, updated.revision, "assets/value.txt")?.toString()).toBe("two")
|
||||
expect(readExtensionAsset(database.db, first.id, first.revision, "assets/value.txt")).toBeUndefined()
|
||||
expect(changed).toEqual(Array(5).fill(first.id))
|
||||
database.close()
|
||||
})
|
||||
|
||||
test("a rejected update preserves the working archive", async () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const manager = createExtensionManager({ db: database.db, fetch, changed() {} })
|
||||
const [first] = await manager.install(await extensionArchive())
|
||||
await expect(manager.install(await extensionArchive({ renderer: "export const broken =" }))).rejects.toThrow(
|
||||
"invalidModule",
|
||||
)
|
||||
await expect(manager.install(await extensionArchive({ id: "opencode.browser" }))).rejects.toThrow("reserved")
|
||||
expect(manager.list()).toEqual([first])
|
||||
expect(manager.source(first.id, first.revision).source).toContain("setup()")
|
||||
database.close()
|
||||
})
|
||||
|
||||
test("archive assets retain byte ranges, HEAD and disabled-state behavior", async () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const manager = createExtensionManager({ db: database.db, fetch, changed() {} })
|
||||
const [entry] = await manager.install(await extensionArchive({ files: { "assets/movie.webm": "0123456789" } }))
|
||||
const input = { id: entry.id, revision: entry.revision, path: "assets/movie.webm" }
|
||||
const range = extensionAssetResponse(database.db, { ...input, range: "bytes=2-5" })
|
||||
expect(range.status).toBe(206)
|
||||
expect(range.headers.get("Content-Range")).toBe("bytes 2-5/10")
|
||||
expect(await range.text()).toBe("2345")
|
||||
expect(await extensionAssetResponse(database.db, { ...input, range: "bytes=-3" }).text()).toBe("789")
|
||||
expect(extensionAssetResponse(database.db, { ...input, range: "bytes=20-" }).status).toBe(416)
|
||||
const head = extensionAssetResponse(database.db, { ...input, head: true })
|
||||
expect(head.headers.get("Content-Length")).toBe("10")
|
||||
expect(await head.text()).toBe("")
|
||||
manager.enable(entry.id, false)
|
||||
expect(extensionAssetResponse(database.db, input).status).toBe(404)
|
||||
database.close()
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ manifest: { schema: 1 } },
|
||||
{ manifest: { entry: "missing.cjs" } },
|
||||
{ manifest: { main: "missing.cjs" } },
|
||||
{ manifest: { entry: "../renderer.cjs" } },
|
||||
{ files: { "../escape.txt": "bad" } },
|
||||
{ files: { "C:/escape.txt": "bad" } },
|
||||
])("validates the archive boundary: %j", async (input) => {
|
||||
await expect(readArchive(await extensionArchive(input))).rejects.toBeInstanceOf(ExtensionManager.ManagerError)
|
||||
})
|
||||
|
||||
test("downloads archives over HTTP and rejects unsupported URLs or failed responses", async () => {
|
||||
const archive = await extensionArchive()
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/extension.ocdx" ? new Response(archive) : new Response(null, { status: 404 }),
|
||||
})
|
||||
const database = openDatabase(":memory:")
|
||||
const manager = createExtensionManager({ db: database.db, fetch, changed() {} })
|
||||
try {
|
||||
expect(await manager.installURL(new URL("extension.ocdx", server.url).href)).toHaveLength(1)
|
||||
await expect(manager.installURL(new URL("missing.ocdx", server.url).href)).rejects.toThrow("download")
|
||||
await expect(manager.installURL("file:///extension.ocdx")).rejects.toThrow("url")
|
||||
} finally {
|
||||
server.stop(true)
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { Schema } from "effect"
|
||||
import type { Database } from "../storage/database"
|
||||
import { extensions, extensionFiles } from "../storage/schema"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import { readArchive, archiveLimit } from "./archive"
|
||||
|
||||
const decodeManifest = Schema.decodeUnknownSync(Schema.fromJsonString(ExtensionManager.Manifest))
|
||||
|
||||
/** Archive bytes and enable state are committed together in Desktop's SQLite store. */
|
||||
export function createExtensionManager(input: {
|
||||
db: Database
|
||||
fetch: (url: string) => Promise<Response>
|
||||
changed(id: string, entries: readonly ExtensionManager.Installed[]): void
|
||||
reserved?: readonly string[]
|
||||
}) {
|
||||
const list = (): ExtensionManager.Installed[] =>
|
||||
input.db
|
||||
.select()
|
||||
.from(extensions)
|
||||
.all()
|
||||
.map((row) => {
|
||||
const manifest = decodeManifest(row.manifest)
|
||||
return {
|
||||
id: row.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
revision: row.revision,
|
||||
generation: row.generation,
|
||||
enabled: row.enabled,
|
||||
hasMain: !!manifest.main,
|
||||
}
|
||||
})
|
||||
const get = (id: string, revision?: string) => {
|
||||
const row = input.db.select().from(extensions).where(eq(extensions.id, id)).get()
|
||||
if (!row || (revision && row.revision !== revision)) throw new ExtensionManager.ManagerError("notFound")
|
||||
if (!row.enabled) throw new ExtensionManager.ManagerError("disabled")
|
||||
return { ...row, manifest: decodeManifest(row.manifest) }
|
||||
}
|
||||
const source = (id: string, revision?: string, main = false): ExtensionManager.Source => {
|
||||
const row = get(id, revision)
|
||||
const entry = main ? row.manifest.main : row.manifest.entry
|
||||
const file =
|
||||
entry &&
|
||||
input.db
|
||||
.select()
|
||||
.from(extensionFiles)
|
||||
.where(and(eq(extensionFiles.extension_id, id), eq(extensionFiles.path, entry)))
|
||||
.get()
|
||||
if (!file) throw new ExtensionManager.ManagerError("notFound")
|
||||
return { manifest: row.manifest, revision: row.revision, source: file.data.toString("utf8") }
|
||||
}
|
||||
const install = async (bytes: Uint8Array) => {
|
||||
const archive = await readArchive(bytes)
|
||||
if (archive.manifest.id.startsWith("opencode.") || input.reserved?.includes(archive.manifest.id))
|
||||
throw new ExtensionManager.ManagerError("reserved")
|
||||
input.db.transaction((db) => {
|
||||
db.delete(extensionFiles).where(eq(extensionFiles.extension_id, archive.manifest.id)).run()
|
||||
db.insert(extensions)
|
||||
.values({
|
||||
id: archive.manifest.id,
|
||||
manifest: JSON.stringify(archive.manifest),
|
||||
revision: archive.revision,
|
||||
generation: 1,
|
||||
enabled: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: extensions.id,
|
||||
set: {
|
||||
manifest: JSON.stringify(archive.manifest),
|
||||
revision: archive.revision,
|
||||
generation: sql`${extensions.generation} + 1`,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
archive.files.forEach((file) =>
|
||||
db.insert(extensionFiles).values({ extension_id: archive.manifest.id, path: file.path, data: file.data }).run(),
|
||||
)
|
||||
})
|
||||
const entries = list()
|
||||
input.changed(archive.manifest.id, entries)
|
||||
return entries
|
||||
}
|
||||
return {
|
||||
list,
|
||||
source,
|
||||
install,
|
||||
enable(id: string, enabled: boolean) {
|
||||
const row = input.db.select().from(extensions).where(eq(extensions.id, id)).get()
|
||||
if (!row) throw new ExtensionManager.ManagerError("notFound")
|
||||
input.db
|
||||
.update(extensions)
|
||||
.set({ enabled, generation: row.generation + 1 })
|
||||
.where(eq(extensions.id, id))
|
||||
.run()
|
||||
const entries = list()
|
||||
input.changed(id, entries)
|
||||
return entries
|
||||
},
|
||||
reload(id: string) {
|
||||
const row = get(id)
|
||||
input.db
|
||||
.update(extensions)
|
||||
.set({ generation: row.generation + 1 })
|
||||
.where(eq(extensions.id, id))
|
||||
.run()
|
||||
const entries = list()
|
||||
input.changed(id, entries)
|
||||
return entries
|
||||
},
|
||||
async installURL(value: string) {
|
||||
if (!URL.canParse(value) || !["http:", "https:"].includes(new URL(value).protocol))
|
||||
throw new ExtensionManager.ManagerError("url")
|
||||
const response = await input.fetch(value).catch(() => {
|
||||
throw new ExtensionManager.ManagerError("download")
|
||||
})
|
||||
if (!response.ok || !response.body) throw new ExtensionManager.ManagerError("download")
|
||||
if (Number(response.headers.get("content-length")) > archiveLimit) {
|
||||
await response.body.cancel()
|
||||
throw new ExtensionManager.ManagerError("tooLarge")
|
||||
}
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
for await (const chunk of response.body) {
|
||||
size += chunk.byteLength
|
||||
if (size > archiveLimit) throw new ExtensionManager.ManagerError("tooLarge")
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return install(Buffer.concat(chunks))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function readExtensionAsset(db: Database, id: string, revision: string, path: string) {
|
||||
const row = db
|
||||
.select()
|
||||
.from(extensions)
|
||||
.where(and(eq(extensions.id, id), eq(extensions.revision, revision), eq(extensions.enabled, true)))
|
||||
.get()
|
||||
if (!row) return
|
||||
return db
|
||||
.select({ data: extensionFiles.data })
|
||||
.from(extensionFiles)
|
||||
.where(and(eq(extensionFiles.extension_id, id), eq(extensionFiles.path, path)))
|
||||
.get()?.data
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createRequire, isBuiltin } from "node:module"
|
||||
import { Schema } from "effect"
|
||||
import { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import { evaluateBundle } from "@opencode/plugin/desktop/bundle"
|
||||
import type { createExtensionManager } from "./manager"
|
||||
|
||||
const native = createRequire(import.meta.url)
|
||||
const shared = new Map<string, () => Promise<unknown>>([
|
||||
["effect", () => import("effect")],
|
||||
["@opencode/plugin/desktop/main", () => import("@opencode/plugin/desktop/main")],
|
||||
["@opencode/schema/rpc", () => import("@opencode/schema/rpc")],
|
||||
["@opencode/client/effect", () => import("@opencode/client/effect")],
|
||||
["@opencode/client", () => import("@opencode/client")],
|
||||
])
|
||||
|
||||
export async function loadMainPlugin(manager: ReturnType<typeof createExtensionManager>, id: string) {
|
||||
const entry = manager.list().find((entry) => entry.id === id && entry.enabled && entry.hasMain)
|
||||
if (!entry) return
|
||||
const input = manager.source(id, entry.revision, true)
|
||||
const modules = new Map(
|
||||
await Promise.all(
|
||||
(input.manifest.mainImports ?? []).map(async (name) => {
|
||||
if (name === "electron" || isBuiltin(name)) return [name, native(name)] as const
|
||||
const load = shared.get(name)
|
||||
if (!load) throw new ExtensionManager.ManagerError("invalidModule")
|
||||
return [name, await load()] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Re-read after module loading so disabling/replacing an archive takes effect
|
||||
// before a new main instance can start.
|
||||
const current = manager.source(id, entry.revision, true)
|
||||
const decoded = Schema.decodeUnknownOption(Schema.Struct({ default: MainPlugin.Entry }))(
|
||||
evaluateBundle(current.source, modules),
|
||||
)
|
||||
if (decoded._tag === "None" || decoded.value.default.id !== id)
|
||||
throw new ExtensionManager.ManagerError("invalidModule")
|
||||
return decoded.value.default
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ImageView, screen, type BrowserWindow, type View } from "electron"
|
||||
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import { createCornerImages } from "../native/corners"
|
||||
|
||||
export function createSurfaces(win: BrowserWindow) {
|
||||
const entries = new Map<string, { extensionID: string; view: View; corners: ImageView[]; key: string }>()
|
||||
const remove = (id: string) => {
|
||||
const entry = entries.get(id)
|
||||
if (!entry) return
|
||||
entries.delete(id)
|
||||
if (win.isDestroyed()) return
|
||||
entry.view.setVisible(false)
|
||||
entry.corners.forEach((corner) => win.contentView.removeChildView(corner))
|
||||
win.contentView.removeChildView(entry.view)
|
||||
}
|
||||
return {
|
||||
register(extensionID: string, view: View) {
|
||||
const id = crypto.randomUUID()
|
||||
view.setBounds({ x: 0, y: 0, width: 1000, height: 700 })
|
||||
view.setVisible(false)
|
||||
win.contentView.addChildView(view)
|
||||
const corners = [new ImageView(), new ImageView()]
|
||||
corners.forEach((corner) => {
|
||||
corner.setVisible(false)
|
||||
win.contentView.addChildView(corner)
|
||||
})
|
||||
entries.set(id, { extensionID, view, corners, key: "" })
|
||||
return { id, dispose: () => remove(id) }
|
||||
},
|
||||
layout(extensionID: string, id: string, layout?: DesktopExtension.Layout) {
|
||||
const entry = entries.get(id)
|
||||
if (!entry || entry.extensionID !== extensionID || win.isDestroyed()) return
|
||||
const bounds = layout?.bounds
|
||||
if (!layout?.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
entry.view.setVisible(false)
|
||||
entry.corners.forEach((corner) => corner.setVisible(false))
|
||||
return
|
||||
}
|
||||
entry.view.setBounds(bounds)
|
||||
const size = Math.min(layout.radius ?? 10, Math.floor(bounds.width / 2), Math.floor(bounds.height / 2))
|
||||
const scale = screen.getDisplayMatching(win.getBounds()).scaleFactor
|
||||
const key = layout.background && size > 0 ? `${layout.background}:${size}:${scale}` : ""
|
||||
if (key && key !== entry.key && layout.background)
|
||||
createCornerImages(layout.background, size, scale).forEach((image, index) =>
|
||||
entry.corners[index].setImage(image),
|
||||
)
|
||||
entry.key = key
|
||||
entry.corners.forEach((corner, index) => {
|
||||
corner.setBounds(
|
||||
{
|
||||
x: bounds.x + (index ? bounds.width - size : 0),
|
||||
y: bounds.y + bounds.height - size,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
{ animate: { duration: 0 } },
|
||||
)
|
||||
corner.setVisible(!!key)
|
||||
})
|
||||
entry.view.setVisible(true)
|
||||
},
|
||||
release(extensionID: string) {
|
||||
entries.forEach((entry, id) => {
|
||||
if (entry.extensionID === extensionID) remove(id)
|
||||
})
|
||||
},
|
||||
dispose() {
|
||||
Array.from(entries.keys()).forEach(remove)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,103 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { BrowserWindow, net } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { createBrowserPane } from "../browser-pane"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { isRendererUrl } from "../windows/protocol"
|
||||
import { sender } from "./context"
|
||||
import { createMainExtensionHost } from "../extensions/host"
|
||||
import { mainExtensions } from "../extensions/builtins"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { ExtensionEvent } from "../../shared/ipc-rpc/events"
|
||||
import { ExtensionsChanged } from "../../shared/ipc-rpc/events"
|
||||
import { ExtensionManagerRpcs } from "../../shared/ipc-rpc/extension-manager"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { createExtensionManager } from "../extensions/manager"
|
||||
import { loadMainPlugin } from "../extensions/module"
|
||||
|
||||
export const eventHandlers = EventRpcs.toLayer(
|
||||
const rpcs = EventRpcs.merge(ExtensionManagerRpcs)
|
||||
export const eventHandlers = rpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const shutdown = yield* Shutdown.Service
|
||||
const browser = createBrowserPane()
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
const storage = yield* DesktopStorage.Service
|
||||
const extensions = createMainExtensionHost(
|
||||
mainExtensions,
|
||||
(win, event) => emitIpcEvent(win.webContents, new ExtensionEvent({ event })),
|
||||
(id) => loadMainPlugin(manager, id),
|
||||
)
|
||||
const manager = createExtensionManager({
|
||||
db: storage.db,
|
||||
fetch: net.fetch,
|
||||
reserved: mainExtensions.map((plugin) => plugin.id),
|
||||
changed(id, entries) {
|
||||
extensions.releaseAll(id)
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (!win.isDestroyed() && isRendererUrl(win.webContents.getURL()))
|
||||
emitIpcEvent(win.webContents, new ExtensionsChanged({ entries }))
|
||||
})
|
||||
},
|
||||
})
|
||||
const authorized = (context: Parameters<typeof sender>[1]) => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL()))
|
||||
throw new ExtensionManager.ManagerError("notFound")
|
||||
}
|
||||
const operation = <Value>(context: Parameters<typeof sender>[1], run: () => Value | Promise<Value>) =>
|
||||
Effect.tryPromise(async () => {
|
||||
try {
|
||||
authorized(context)
|
||||
return { ok: true as const, entries: await run() }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: { code: error instanceof ExtensionManager.ManagerError ? error.code : ("storage" as const) },
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.orDie)
|
||||
const stop = Effect.promise(() => extensions.dispose())
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
BrowserPane: ({ request }, context) =>
|
||||
return rpcs.of({
|
||||
ExtensionManagerList: (_request, context) =>
|
||||
Effect.sync(() => {
|
||||
authorized(context)
|
||||
return manager.list()
|
||||
}),
|
||||
ExtensionManagerInstall: ({ data }, context) => operation(context, () => manager.install(data)),
|
||||
ExtensionManagerInstallURL: ({ url }, context) => operation(context, () => manager.installURL(url)),
|
||||
ExtensionManagerEnable: ({ id, enabled }, context) => operation(context, () => manager.enable(id, enabled)),
|
||||
ExtensionManagerReload: ({ id }, context) => operation(context, () => manager.reload(id)),
|
||||
ExtensionManagerSource: ({ id, revision }, context) =>
|
||||
Effect.sync(() => {
|
||||
try {
|
||||
authorized(context)
|
||||
return { ok: true as const, value: manager.source(id, revision) }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: { code: error instanceof ExtensionManager.ManagerError ? error.code : ("storage" as const) },
|
||||
}
|
||||
}
|
||||
}),
|
||||
DesktopExtension: ({ request }, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
|
||||
throw new Error("browser.pane.owner.invalid")
|
||||
}
|
||||
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
|
||||
return browser.close(win, request.bindingID)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL()))
|
||||
throw new Error("Desktop extension owner is unavailable")
|
||||
if (request.type === "call") return extensions.call(win, request.call)
|
||||
if (request.type === "cancel") extensions.cancel(win, request.extensionID, request.requestID)
|
||||
if (request.type === "servers") extensions.configure(win, request.servers)
|
||||
if (request.type === "surface")
|
||||
extensions.surface(win, request.extensionID, request.surfaceID, request.layout)
|
||||
if (request.type === "release") extensions.release(win, request.extensionID)
|
||||
return null
|
||||
}).pipe(Effect.orDie),
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { nativeImage } from "electron"
|
||||
|
||||
// Native browser surfaces ignore a parent View's clip path. Cover only the
|
||||
// Native surfaces ignore a parent View's clip path. Cover only the
|
||||
// pixels outside the bottom arcs; never resize or style the page itself.
|
||||
export function createCornerImages(color: readonly [number, number, number, number], radius: number, scale: number) {
|
||||
const size = Math.max(1, Math.round(radius * scale))
|
||||
@@ -14,7 +14,14 @@ const tables = (db: ReturnType<typeof drizzle>) =>
|
||||
describe("database", () => {
|
||||
test("bootstraps every table on a fresh database and is idempotent", () => {
|
||||
const database = openDatabase(":memory:")
|
||||
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
|
||||
expect(tables(database.db)).toEqual([
|
||||
"blob",
|
||||
"desktop_extension",
|
||||
"desktop_extension_file",
|
||||
"document",
|
||||
"migration",
|
||||
"state",
|
||||
])
|
||||
expect(migrate(database.db)).toEqual([])
|
||||
database.close()
|
||||
})
|
||||
@@ -26,7 +33,14 @@ describe("database", () => {
|
||||
)
|
||||
const db = drizzle({ client: native })
|
||||
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
|
||||
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
|
||||
expect(tables(db)).toEqual([
|
||||
"blob",
|
||||
"desktop_extension",
|
||||
"desktop_extension_file",
|
||||
"document",
|
||||
"migration",
|
||||
"state",
|
||||
])
|
||||
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
|
||||
expect(migrate(db)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -18,4 +18,11 @@ export const migrations = [
|
||||
id: "20260907031611_blob-touched",
|
||||
statements: ["ALTER TABLE `blob` ADD `touched_at` integer DEFAULT 0 NOT NULL;"],
|
||||
},
|
||||
{
|
||||
id: "20260908211404_desktop-extensions",
|
||||
statements: [
|
||||
"CREATE TABLE `desktop_extension_file` (\n\t`extension_id` text NOT NULL,\n\t`path` text NOT NULL,\n\t`data` blob NOT NULL,\n\tCONSTRAINT `desktop_extension_file_pk` PRIMARY KEY(`extension_id`, `path`)\n);",
|
||||
"CREATE TABLE `desktop_extension` (\n\t`id` text PRIMARY KEY,\n\t`manifest` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`generation` integer NOT NULL,\n\t`enabled` integer NOT NULL\n);",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `desktop_extension_file` (
|
||||
`extension_id` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`data` blob NOT NULL,
|
||||
CONSTRAINT `desktop_extension_file_pk` PRIMARY KEY(`extension_id`, `path`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `desktop_extension` (
|
||||
`id` text PRIMARY KEY,
|
||||
`manifest` text NOT NULL,
|
||||
`revision` text NOT NULL,
|
||||
`generation` integer NOT NULL,
|
||||
`enabled` integer NOT NULL
|
||||
);
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "373d581e-0c79-4a9d-9a31-616c3a734892",
|
||||
"prevIds": [
|
||||
"53c65132-8703-42d6-8464-64356145dfb4"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "blob",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "document",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "desktop_extension_file",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "desktop_extension",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "touched_at",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "extension_id",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension_file"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "path",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension_file"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension_file"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "manifest",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "revision",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "generation",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "enabled",
|
||||
"entityType": "columns",
|
||||
"table": "desktop_extension"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "updated_at",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"extension_id",
|
||||
"path"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "desktop_extension_file_pk",
|
||||
"entityType": "pks",
|
||||
"table": "desktop_extension_file"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"name",
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "state_pk",
|
||||
"entityType": "pks",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "blob_pk",
|
||||
"table": "blob",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "document_pk",
|
||||
"table": "document",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "desktop_extension_pk",
|
||||
"table": "desktop_extension",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -27,3 +27,21 @@ export const state = sqliteTable(
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.name, table.key] })],
|
||||
)
|
||||
|
||||
export const extensions = sqliteTable("desktop_extension", {
|
||||
id: text().primaryKey(),
|
||||
manifest: text().notNull(),
|
||||
revision: text().notNull(),
|
||||
generation: integer().notNull(),
|
||||
enabled: integer({ mode: "boolean" }).notNull(),
|
||||
})
|
||||
|
||||
export const extensionFiles = sqliteTable(
|
||||
"desktop_extension_file",
|
||||
{
|
||||
extension_id: text().notNull(),
|
||||
path: text().notNull(),
|
||||
data: blob({ mode: "buffer" }).notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.extension_id, table.path] })],
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Effect, Path } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { extensionAssetResponse } from "../extensions/assets"
|
||||
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
@@ -16,6 +18,7 @@ protocol.registerSchemesAsPrivileged([
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
@@ -24,11 +27,26 @@ protocol.registerSchemesAsPrivileged([
|
||||
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
const storage = yield* DesktopStorage.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host === "extensions") {
|
||||
const [id, revision, ...parts] = url.pathname.slice(1).split("/").map(decodeURIComponent)
|
||||
if (!id || !revision) return new Response(null, { status: 404 })
|
||||
const response = extensionAssetResponse(storage.db, {
|
||||
id,
|
||||
revision,
|
||||
path: parts.join("/"),
|
||||
range: request.headers.get("range"),
|
||||
head: request.method === "HEAD",
|
||||
})
|
||||
const origin = request.headers.get("origin")
|
||||
if (origin && isRendererUrl(origin)) response.headers.set("Access-Control-Allow-Origin", origin)
|
||||
return response
|
||||
}
|
||||
if (url.host !== rendererHost) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BrowserPaneEvent } from "@opencode/app/desktop"
|
||||
import type { DesktopMenuAction } from "@opencode/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode/app/wsl/types"
|
||||
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
|
||||
import type { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import type { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
import type {
|
||||
ClipboardImage,
|
||||
DirectoryPickerOptions,
|
||||
@@ -23,13 +23,10 @@ export type UpdaterAPI = {
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
extensions: DesktopExtension.Transport
|
||||
extensionManager: ExtensionManager.Transport
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
browserPane: {
|
||||
request(request: BrowserPaneRequest): Promise<void>
|
||||
send(request: BrowserPaneRequest): void
|
||||
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
|
||||
}
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ElectronAPI } from "./api-types"
|
||||
import type { UpdaterState } from "@opencode/app/updater"
|
||||
import { invoke, listen, send } from "./ipc-client"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
|
||||
type Mutable<Value> =
|
||||
Value extends ReadonlyArray<unknown>
|
||||
@@ -23,13 +24,45 @@ const updaterHandler = (state: UpdaterState) => {
|
||||
}
|
||||
|
||||
export const api: ElectronAPI = {
|
||||
extensionManager: {
|
||||
list: () => invoke("ExtensionManagerList"),
|
||||
install: (data) => invoke("ExtensionManagerInstall", { data }).then(extensionResult),
|
||||
installURL: (url) => invoke("ExtensionManagerInstallURL", { url }).then(extensionResult),
|
||||
enable: (id, enabled) => invoke("ExtensionManagerEnable", { id, enabled }).then(extensionResult),
|
||||
reload: (id) => invoke("ExtensionManagerReload", { id }).then(extensionResult),
|
||||
source: (id, revision) =>
|
||||
invoke("ExtensionManagerSource", { id, revision }).then((result) => {
|
||||
if (!result.ok) throw new ExtensionManager.ManagerError(result.error.code)
|
||||
return result.value
|
||||
}),
|
||||
onChange: (callback) => listen("ExtensionsChanged", (event) => callback(event.entries)),
|
||||
assetURL: (id, revision, path) =>
|
||||
`oc://extensions/${encodeURIComponent(id)}/${revision}/${path.split("/").map(encodeURIComponent).join("/")}`,
|
||||
},
|
||||
extensions: {
|
||||
call(input, signal) {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason)
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancel = () => {
|
||||
send("DesktopExtension", {
|
||||
request: { type: "cancel", extensionID: input.extensionID, requestID: input.requestID },
|
||||
})
|
||||
reject(signal?.reason)
|
||||
}
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
void invoke("DesktopExtension", { request: { type: "call", call: input } })
|
||||
.then(resolve, reject)
|
||||
.finally(() => signal?.removeEventListener("abort", cancel))
|
||||
})
|
||||
},
|
||||
onEvent: (callback) => listen("ExtensionEvent", ({ event }) => callback(event)),
|
||||
surface: (extensionID, surfaceID, layout) =>
|
||||
send("DesktopExtension", { request: { type: "surface", extensionID, surfaceID, layout } }),
|
||||
configure: (servers) => send("DesktopExtension", { request: { type: "servers", servers } }),
|
||||
release: (extensionID) => send("DesktopExtension", { request: { type: "release", extensionID } }),
|
||||
},
|
||||
awaitInitialization: () => invoke("AppAwaitInitialization"),
|
||||
reconnectService: () => invoke("AppReconnectService"),
|
||||
browserPane: {
|
||||
request: (request) => invoke("BrowserPane", { request }),
|
||||
send: (request) => send("BrowserPane", { request }),
|
||||
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
|
||||
},
|
||||
wslServers: {
|
||||
getState: () => invoke("WslGetState").then(mutable),
|
||||
subscribe: (cb) => {
|
||||
@@ -131,3 +164,12 @@ export const api: ElectronAPI = {
|
||||
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
|
||||
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
|
||||
}
|
||||
|
||||
function extensionResult(
|
||||
result:
|
||||
| { readonly ok: true; readonly entries: readonly ExtensionManager.Installed[] }
|
||||
| { readonly ok: false; readonly error: { readonly code: ExtensionManager.ErrorCode } },
|
||||
) {
|
||||
if (!result.ok) throw new ExtensionManager.ManagerError(result.error.code)
|
||||
return result.entries
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
ACCEPTED_FILE_EXTENSIONS,
|
||||
ServerConnection,
|
||||
type Platform,
|
||||
type UpdaterPlatform,
|
||||
} from "@opencode/app/desktop"
|
||||
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode/app/desktop"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
|
||||
import { windowFullscreen } from "../window/fullscreen"
|
||||
@@ -11,6 +6,7 @@ import { DragCancelEvent } from "../../shared/ipc-transport"
|
||||
import { createDesktopFiles } from "./files"
|
||||
import { createDesktopMenuAction } from "./menu"
|
||||
import { createDesktopNotify } from "./notifications"
|
||||
import BrowserExtension from "@opencode/plugin-browser-desktop"
|
||||
import { createDesktopStorage } from "./storage"
|
||||
|
||||
export type DesktopWindowState = {
|
||||
@@ -29,37 +25,11 @@ export function createDesktopPlatform(
|
||||
os,
|
||||
version: windowState.version,
|
||||
windowID: windowState.id,
|
||||
extensions: api.extensions,
|
||||
extensionManager: api.extensionManager,
|
||||
extensionPlugins: [BrowserExtension],
|
||||
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
|
||||
...createDesktopStorage(api),
|
||||
browserPane: {
|
||||
register(target, onEvent) {
|
||||
const bindingID = crypto.randomUUID()
|
||||
let closed = false
|
||||
const dispose = api.browserPane.onEvent((value) => {
|
||||
if (!closed && value.bindingID === bindingID) onEvent(value.event)
|
||||
})
|
||||
const ready = api.browserPane.request({ type: "register", bindingID, target })
|
||||
// Failures reach the owner through the closed-state event; keep the bare promise handled.
|
||||
void ready.catch(() => undefined)
|
||||
return {
|
||||
setLayout(layout) {
|
||||
if (!closed)
|
||||
void ready
|
||||
.then(() =>
|
||||
api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
dispose()
|
||||
void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
updater,
|
||||
exportDebugLogs: () => api.exportDebugLogs(),
|
||||
setForceFocus: (enabled) => api.setForceFocus(enabled),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StorageRpcs } from "./ipc-rpc/storage"
|
||||
import { UpdaterRpcs } from "./ipc-rpc/updater"
|
||||
import { WindowRpcs } from "./ipc-rpc/window"
|
||||
import { WslRpcs } from "./ipc-rpc/wsl"
|
||||
import { ExtensionManagerRpcs } from "./ipc-rpc/extension-manager"
|
||||
|
||||
export { AppRpcs } from "./ipc-rpc/app"
|
||||
export { EventRpcs } from "./ipc-rpc/events"
|
||||
@@ -17,5 +18,14 @@ export { UpdaterRpcs } from "./ipc-rpc/updater"
|
||||
export { WindowRpcs } from "./ipc-rpc/window"
|
||||
export { WslRpcs } from "./ipc-rpc/wsl"
|
||||
|
||||
export const DesktopRpcs = AppRpcs.merge(StorageRpcs, FileRpcs, WindowRpcs, MenuRpcs, UpdaterRpcs, WslRpcs, EventRpcs)
|
||||
export const DesktopRpcs = AppRpcs.merge(
|
||||
StorageRpcs,
|
||||
FileRpcs,
|
||||
WindowRpcs,
|
||||
MenuRpcs,
|
||||
UpdaterRpcs,
|
||||
WslRpcs,
|
||||
EventRpcs,
|
||||
ExtensionManagerRpcs,
|
||||
)
|
||||
export type DesktopRpcClient = RpcClient.FromGroup<typeof DesktopRpcs, RpcClientError.RpcClientError>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { Schema } from "effect"
|
||||
import { Rpc } from "effect/unstable/rpc"
|
||||
|
||||
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
|
||||
const bindingID = text(128)
|
||||
const endpoint = Schema.Struct({
|
||||
url: text(16_384),
|
||||
username: Schema.optionalKey(text(1_024)),
|
||||
password: Schema.optionalKey(text(4_096)),
|
||||
})
|
||||
const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint })
|
||||
const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
|
||||
const channel = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 }))
|
||||
const layout = Schema.Struct({
|
||||
tabID: Browser.TabID,
|
||||
visible: Schema.Boolean,
|
||||
bounds: Schema.optionalKey(bounds),
|
||||
background: Schema.optionalKey(Schema.Tuple([channel, channel, channel, channel])),
|
||||
radius: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
||||
})
|
||||
export const BrowserPaneRequestSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
|
||||
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
|
||||
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
|
||||
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
|
||||
])
|
||||
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
|
||||
|
||||
export const BrowserPaneEventSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("state"),
|
||||
state: Schema.NullOr(Browser.State),
|
||||
error: Schema.optionalKey(Schema.String),
|
||||
}),
|
||||
])
|
||||
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
|
||||
import { UpdaterStateSchema } from "./updater"
|
||||
import { WslServersEventSchema } from "./wsl"
|
||||
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import { ExtensionRpc } from "./extensions"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
|
||||
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
|
||||
bindingID: Schema.String,
|
||||
event: BrowserPaneEventSchema,
|
||||
export class ExtensionsChanged extends Schema.TaggedClass<ExtensionsChanged>()("ExtensionsChanged", {
|
||||
entries: Schema.Array(ExtensionManager.Installed),
|
||||
}) {}
|
||||
|
||||
export class ExtensionEvent extends Schema.TaggedClass<ExtensionEvent>()("ExtensionEvent", {
|
||||
event: DesktopExtension.Event,
|
||||
}) {}
|
||||
|
||||
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
|
||||
@@ -46,7 +51,8 @@ export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("Storag
|
||||
}) {}
|
||||
|
||||
export const DesktopEvent = Schema.Union([
|
||||
BrowserPaneEvent,
|
||||
ExtensionsChanged,
|
||||
ExtensionEvent,
|
||||
DeepLinksOpened,
|
||||
MenuCommandTriggered,
|
||||
UpdaterStateChanged,
|
||||
@@ -59,4 +65,4 @@ export const DesktopEvent = Schema.Union([
|
||||
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
|
||||
|
||||
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, ExtensionRpc)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { ExtensionManager } from "@opencode/plugin/desktop/manager"
|
||||
|
||||
const Inventory = Schema.Array(ExtensionManager.Installed)
|
||||
const Result = Schema.Union([
|
||||
Schema.Struct({ ok: Schema.Literal(true), entries: Inventory }),
|
||||
Schema.Struct({ ok: Schema.Literal(false), error: ExtensionManager.Failure }),
|
||||
])
|
||||
export const ExtensionManagerRpcs = RpcGroup.make(
|
||||
Rpc.make("ExtensionManagerList", { success: Inventory }),
|
||||
Rpc.make("ExtensionManagerInstall", { payload: { data: Schema.Uint8Array }, success: Result }),
|
||||
Rpc.make("ExtensionManagerInstallURL", { payload: { url: Schema.String }, success: Result }),
|
||||
Rpc.make("ExtensionManagerEnable", { payload: { id: Schema.String, enabled: Schema.Boolean }, success: Result }),
|
||||
Rpc.make("ExtensionManagerReload", { payload: { id: Schema.String }, success: Result }),
|
||||
Rpc.make("ExtensionManagerSource", {
|
||||
payload: { id: Schema.String, revision: Schema.String },
|
||||
success: Schema.Union([
|
||||
Schema.Struct({ ok: Schema.Literal(true), value: ExtensionManager.Source }),
|
||||
Schema.Struct({ ok: Schema.Literal(false), error: ExtensionManager.Failure }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DesktopExtension } from "@opencode/plugin/desktop/protocol"
|
||||
import { Schema } from "effect"
|
||||
import { Rpc } from "effect/unstable/rpc"
|
||||
|
||||
export const ExtensionRequest = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("call"), call: DesktopExtension.Call }),
|
||||
Schema.Struct({ type: Schema.Literal("cancel"), extensionID: Schema.String, requestID: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("servers"), servers: Schema.Array(DesktopExtension.Endpoint) }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("surface"),
|
||||
extensionID: Schema.String,
|
||||
surfaceID: Schema.String,
|
||||
layout: Schema.optionalKey(DesktopExtension.Layout),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("release"), extensionID: Schema.String }),
|
||||
])
|
||||
export type ExtensionRequest = typeof ExtensionRequest.Type
|
||||
export const ExtensionRpc = Rpc.make("DesktopExtension", {
|
||||
payload: { request: ExtensionRequest },
|
||||
success: Schema.Json,
|
||||
})
|
||||
@@ -6,9 +6,10 @@ import path from "node:path"
|
||||
import { app, BrowserWindow, nativeImage } from "electron"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { createBrowserPane } from "../../src/main/browser-pane"
|
||||
import { bindIpcEvents, ipcEventStream } from "../../src/main/ipc-events"
|
||||
import { Schema } from "effect"
|
||||
import BrowserExtension from "@opencode/plugin-browser-desktop/main"
|
||||
import { BrowserDesktop } from "@opencode/plugin-browser-desktop/rpc"
|
||||
import { createMainExtensionHost } from "../../src/main/extensions/host"
|
||||
import { Smoke } from "./contract"
|
||||
import { verifyTargets } from "./targets"
|
||||
|
||||
@@ -126,7 +127,6 @@ async function main() {
|
||||
const location = { directory: process.env.SMOKE_SERVER_FILES! }
|
||||
const rpc = client.rpc(Smoke)
|
||||
const session = await client.session.create({ title: "Browser suite", location })
|
||||
const pane = createBrowserPane()
|
||||
const win = new BrowserWindow({ show: false, width: 1100, height: 800, webPreferences: { sandbox: true } })
|
||||
const readyToShow = once(win, "ready-to-show")
|
||||
await win.loadURL("about:blank")
|
||||
@@ -139,28 +139,40 @@ async function main() {
|
||||
const ipcErrors: string[] = []
|
||||
const replaced: string[] = []
|
||||
const inventories = new Map<string, Browser.State | null>()
|
||||
const unbind = await Effect.runPromise(bindIpcEvents(win.webContents.id))
|
||||
const events = Effect.runFork(
|
||||
ipcEventStream(win.webContents.id).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event._tag !== "BrowserPaneEvent") return
|
||||
if (event.event.type === "state") {
|
||||
if (event.event.error === "browser.pane.replaced") replaced.push(event.bindingID)
|
||||
inventories.set(event.bindingID, event.event.state)
|
||||
return
|
||||
}
|
||||
if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID))
|
||||
ipcErrors.push("Focus arrived before its tab inventory")
|
||||
pane.layout(win, event.bindingID, {
|
||||
tabID: event.event.tabID,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 1000, height: 700 },
|
||||
})
|
||||
}),
|
||||
const surfaces = new Map<string, Readonly<Record<string, string>>>()
|
||||
const pane = createMainExtensionHost([BrowserExtension], (_win, payload) => {
|
||||
const event = Schema.decodeUnknownSync(BrowserDesktop.Definition.events.changed.schema)(payload.data)
|
||||
if (event.event.type === "state") {
|
||||
if (event.event.error === "browser.pane.replaced") replaced.push(event.bindingID)
|
||||
inventories.set(event.bindingID, event.event.state)
|
||||
surfaces.set(event.bindingID, event.event.surfaces)
|
||||
return
|
||||
}
|
||||
if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID))
|
||||
ipcErrors.push("Focus arrived before its tab inventory")
|
||||
layout(event.bindingID, event.event.tabID)
|
||||
})
|
||||
pane.configure(win, [{ id: "fixture", url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD }])
|
||||
function layout(bindingID: string, tabID: Browser.TabID) {
|
||||
Object.entries(surfaces.get(bindingID) ?? {}).forEach(([id, surfaceID]) =>
|
||||
pane.surface(
|
||||
win,
|
||||
BrowserExtension.id,
|
||||
surfaceID,
|
||||
id === tabID ? { visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } } : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
async function register(bindingID: string) {
|
||||
const result = await pane.call(win, {
|
||||
extensionID: BrowserExtension.id,
|
||||
rpcID: BrowserDesktop.Definition.id,
|
||||
method: "register",
|
||||
requestID: crypto.randomUUID(),
|
||||
input: { bindingID, sessionID: session.id, serverID: "fixture" },
|
||||
})
|
||||
assert.deepEqual(result, { ok: true, output: null })
|
||||
}
|
||||
const visited = new Set<Browser.Method>()
|
||||
async function call<Name extends Browser.Method>(
|
||||
name: Name,
|
||||
@@ -187,10 +199,7 @@ async function main() {
|
||||
}
|
||||
try {
|
||||
await verifyTargets(win, fixture)
|
||||
await pane.register(win, "suite", {
|
||||
sessionID: session.id,
|
||||
endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD },
|
||||
})
|
||||
await register("suite")
|
||||
const first = await call("tabs.open", { url: fixture })
|
||||
const second = await call("tabs.open", { url: `${fixture}/other`, focus: false })
|
||||
assert.equal((await call("tabs.list", {})).tabs.length, 2)
|
||||
@@ -290,9 +299,9 @@ async function main() {
|
||||
await new Promise(resolve=>{const frame=document.querySelector('iframe'); frame.onload=()=>resolve(null); frame.src=${JSON.stringify(`http://localhost:${address.port}/frame`)};});
|
||||
})()`,
|
||||
})
|
||||
pane.layout(win, "suite", { tabID, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
|
||||
layout("suite", tabID)
|
||||
await call("tabs.focus", { tabID: second.id })
|
||||
pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
|
||||
layout("suite", second.id)
|
||||
const snap = await call("snapshot", { tabID, boxes: true })
|
||||
const ref = (text: string) => {
|
||||
const match = snap.content
|
||||
@@ -546,18 +555,13 @@ async function main() {
|
||||
[],
|
||||
)
|
||||
assert.deepEqual(ipcErrors, [])
|
||||
await pane.register(win, "replacement", {
|
||||
sessionID: session.id,
|
||||
endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD },
|
||||
})
|
||||
await register("replacement")
|
||||
await until(async () => replaced.includes("suite"))
|
||||
console.log(
|
||||
`PASS ${visited.size} browser operations over physical authenticated HTTP, including file bytes in both directions`,
|
||||
)
|
||||
} finally {
|
||||
await pane.dispose()
|
||||
await Effect.runPromise(Fiber.interrupt(events))
|
||||
await Effect.runPromise(unbind)
|
||||
win.destroy()
|
||||
web.closeAllConnections()
|
||||
await new Promise<void>((resolve) => web.close(() => resolve()))
|
||||
|
||||
@@ -2,16 +2,19 @@ import assert from "node:assert/strict"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { Schema } from "effect"
|
||||
import { createBrowserPage } from "../../src/main/browser-chromium"
|
||||
import { createCornerImages } from "../../src/main/browser/corners"
|
||||
import { createBrowserPage } from "@opencode/plugin-browser-desktop/page"
|
||||
import { createCornerImages } from "../../src/main/native/corners"
|
||||
import { createSurfaces } from "../../src/main/extensions/surfaces"
|
||||
|
||||
export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
const children = win.contentView.children.length
|
||||
const surfaces = createSurfaces(win)
|
||||
const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`)
|
||||
const page = createBrowserPage(win, {
|
||||
id: tabID,
|
||||
partition: `target-test-${crypto.randomUUID()}`,
|
||||
network: null,
|
||||
surfaces: { register: (view) => surfaces.register("test", view) },
|
||||
publish() {},
|
||||
fail() {
|
||||
throw new Error("Target test page failed")
|
||||
@@ -46,8 +49,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
{ x: 30, y: 40, width: 500, height: 300 },
|
||||
{ x: 50, y: 60, width: 600, height: 400 },
|
||||
]) {
|
||||
page.layout(bounds, [255, 255, 255, 255], 10)
|
||||
page.setVisible(true)
|
||||
surfaces.layout("test", page.surfaceID, { visible: true, bounds, background: [255, 255, 255, 255], radius: 10 })
|
||||
await page.contents.executeJavaScript(
|
||||
"new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))))",
|
||||
)
|
||||
@@ -57,7 +59,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
height: bounds.height,
|
||||
})
|
||||
assert.equal(win.contentView.children.length, children + 3)
|
||||
page.setVisible(false)
|
||||
surfaces.layout("test", page.surfaceID)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
}
|
||||
await execute({ type: "navigate", tabID, url })
|
||||
@@ -119,6 +121,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
assert(retained.resources.includes(url + "/"))
|
||||
} finally {
|
||||
await page.dispose()
|
||||
surfaces.dispose()
|
||||
assert.equal(win.contentView.children.length, children)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
test.skipIf(!!process.env.CI)(
|
||||
"installed main extensions hot-load, reload, replace and dispose in Electron",
|
||||
async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(process.platform === "win32" ? "C:/tmp/opencode" : tmpdir(), "extension-native-"),
|
||||
)
|
||||
const output = await mkdtemp(path.resolve(import.meta.dir, "../node_modules/.extension-native-"))
|
||||
const built = await Bun.build({
|
||||
entrypoints: [path.join(import.meta.dir, "extensions/native.ts")],
|
||||
outdir: output,
|
||||
naming: "native.cjs",
|
||||
target: "node",
|
||||
format: "cjs",
|
||||
external: ["electron"],
|
||||
define: {
|
||||
"import.meta": JSON.stringify({
|
||||
url: pathToFileURL(path.join(output, "native.cjs")).href,
|
||||
env: { OPENCODE_CHANNEL: "dev" },
|
||||
}),
|
||||
},
|
||||
})
|
||||
if (!built.success) throw new AggregateError(built.logs)
|
||||
await Bun.write(path.join(output, "entry.cjs"), Bun.file(path.join(import.meta.dir, "extensions/entry.cjs")))
|
||||
const electron: unknown = (await import("electron")).default
|
||||
if (typeof electron !== "string") throw new Error("Electron binary is unavailable")
|
||||
const child = Bun.spawn([electron, path.join(output, "entry.cjs")], {
|
||||
cwd: output,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined, EXTENSION_TEST_HOME: root },
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
try {
|
||||
expect(await child.exited).toBe(0)
|
||||
} finally {
|
||||
if (child.exitCode === null) {
|
||||
child.kill()
|
||||
await child.exited
|
||||
}
|
||||
await Promise.all([rm(root, { recursive: true, force: true }), rm(output, { recursive: true, force: true })])
|
||||
}
|
||||
},
|
||||
30000,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
const { app } = require("electron")
|
||||
const fail = (error) => {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
}
|
||||
// Install error handlers before loading any production modules. Electron's
|
||||
// default startup handler otherwise opens a native dialog on the user's desktop.
|
||||
process.on("uncaughtException", fail)
|
||||
process.on("unhandledRejection", fail)
|
||||
try {
|
||||
require("./native.cjs")
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Uint8ArrayWriter, TextReader, ZipWriter } from "@zip.js/zip.js"
|
||||
|
||||
export async function extensionArchive(
|
||||
input: {
|
||||
id?: string
|
||||
version?: string
|
||||
renderer?: string
|
||||
main?: string
|
||||
files?: Record<string, string>
|
||||
manifest?: Record<string, unknown>
|
||||
} = {},
|
||||
) {
|
||||
const id = input.id ?? "test.lifecycle"
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter())
|
||||
await writer.add(
|
||||
"manifest.json",
|
||||
new TextReader(
|
||||
JSON.stringify({
|
||||
schema: "opencode.desktop/1",
|
||||
id,
|
||||
name: "File utilities",
|
||||
version: input.version ?? "1.0.0",
|
||||
entry: "renderer.cjs",
|
||||
imports: ["@opencode/plugin/desktop"],
|
||||
...(input.main
|
||||
? { main: "main.cjs", mainImports: ["@opencode/plugin/desktop/main", "@opencode/schema/rpc", "effect"] }
|
||||
: {}),
|
||||
...input.manifest,
|
||||
}),
|
||||
),
|
||||
)
|
||||
await writer.add(
|
||||
"renderer.cjs",
|
||||
new TextReader(input.renderer ?? `module.exports.default = { id: ${JSON.stringify(id)}, setup() {} }`),
|
||||
)
|
||||
if (input.main) await writer.add("main.cjs", new TextReader(input.main))
|
||||
for (const [name, text] of Object.entries(input.files ?? {})) await writer.add(name, new TextReader(text))
|
||||
return writer.close()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { DesktopStorage } from "../../src/main/storage"
|
||||
import { registerRendererProtocol } from "../../src/main/windows/protocol"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import { once } from "node:events"
|
||||
import { createExtensionManager } from "../../src/main/extensions/manager"
|
||||
import { loadMainPlugin } from "../../src/main/extensions/module"
|
||||
import { createMainExtensionHost } from "../../src/main/extensions/host"
|
||||
import { extensionArchive } from "./fixture"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
async function mainTest() {
|
||||
app.setPath("userData", process.env.EXTENSION_TEST_HOME!)
|
||||
await app.whenReady()
|
||||
const web = createServer((_request, response) => {
|
||||
response.setHeader("content-type", "text/html")
|
||||
response.end("<title>Fixture</title>")
|
||||
})
|
||||
web.listen(0, "127.0.0.1")
|
||||
await once(web, "listening")
|
||||
const address = web.address()
|
||||
if (!address || typeof address === "string") throw new Error("Fixture address is unavailable")
|
||||
process.env.ELECTRON_RENDERER_URL = `http://127.0.0.1:${address.port}`
|
||||
const database = DesktopStorage.make(":memory:")
|
||||
await Effect.runPromise(
|
||||
registerRendererProtocol().pipe(
|
||||
Effect.provideService(DesktopStorage.Service, database),
|
||||
Effect.provide(NodePath.layer),
|
||||
),
|
||||
)
|
||||
const win = new BrowserWindow({ show: false })
|
||||
await win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
const manager = createExtensionManager({ db: database.db, fetch, changed: (id) => host.releaseAll(id) })
|
||||
const host = createMainExtensionHost(
|
||||
[],
|
||||
() => {},
|
||||
(id) => loadMainPlugin(manager, id),
|
||||
)
|
||||
const main = (version: string) => `
|
||||
const { MainPlugin } = require('@opencode/plugin/desktop/main');
|
||||
const { Rpc } = require('@opencode/schema/rpc');
|
||||
const { Schema } = require('effect');
|
||||
module.exports.default = MainPlugin.define({ id: 'test.lifecycle', rpc: Rpc.define({ id: 'test.lifecycle', methods: { ping: { input: Schema.String, output: Schema.String } }, events: {} }), setup(ctx) {
|
||||
ctx.window.setTitle(${JSON.stringify(version)});
|
||||
ctx.lifecycle.own(() => ctx.window.setTitle('disposed'));
|
||||
return { ping: (value) => ${JSON.stringify(version)} + ':' + value };
|
||||
} });`
|
||||
const call = () =>
|
||||
host.call(win, {
|
||||
extensionID: "test.lifecycle",
|
||||
rpcID: "test.lifecycle",
|
||||
method: "ping",
|
||||
requestID: crypto.randomUUID(),
|
||||
input: "hello",
|
||||
})
|
||||
try {
|
||||
const [installed] = await manager.install(
|
||||
await extensionArchive({ main: main("one"), files: { "assets/value.txt": "native asset" } }),
|
||||
)
|
||||
const asset = `oc://extensions/${installed.id}/${installed.revision}/assets/value.txt`
|
||||
assert.equal(
|
||||
await win.webContents.executeJavaScript(`fetch(${JSON.stringify(asset)}).then(response => response.text())`),
|
||||
"native asset",
|
||||
)
|
||||
assert.deepEqual(await call(), { ok: true, output: "one:hello" })
|
||||
assert.equal(win.getTitle(), "one")
|
||||
manager.reload("test.lifecycle")
|
||||
assert.equal(win.getTitle(), "disposed")
|
||||
assert.deepEqual(await call(), { ok: true, output: "one:hello" })
|
||||
manager.enable("test.lifecycle", false)
|
||||
assert.equal(win.getTitle(), "disposed")
|
||||
assert.equal(((await call()) as { ok: boolean }).ok, false)
|
||||
await manager.install(await extensionArchive({ version: "2.0.0", main: main("two") }))
|
||||
assert.deepEqual(await call(), { ok: true, output: "two:hello" })
|
||||
assert.equal(win.getTitle(), "two")
|
||||
await host.dispose()
|
||||
assert.equal(win.getTitle(), "disposed")
|
||||
win.destroy()
|
||||
database.close()
|
||||
web.close()
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
}
|
||||
}
|
||||
void mainTest().catch((error) => {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
# Built-in Desktop browser extension (draft)
|
||||
|
||||
This package is the Desktop companion to `@opencode/plugin-browser`. It imports the public Plugin, Client, Schema, UI and Electron APIs. Production code does not import App, Desktop, Core or Server internals.
|
||||
|
||||
- `.` is the renderer plugin: settings, availability, attachment lifetime, dynamic panel instances, and the browser toolbar.
|
||||
- `./main` is the trusted native plugin: Chromium, CDP, diagnostics, file capture and remote networking.
|
||||
- `./rpc` is its local renderer/main contract. The existing server-side `@opencode/plugin-browser/rpc` contract remains unchanged.
|
||||
|
||||
The host owns tab layout, selection, closing, keyboard interaction, and native-surface bounds/clipping/occlusion. The extension owns browser pages and protocol behavior. Its UI is composed from shared OpenCode components, including `Panel`, `NativeSurface`, `Toolbar`, `TextInput`, `IconButton`, `Switch` and `SettingsRow`.
|
||||
|
||||
## Ownership
|
||||
|
||||
Every visited session attaches eagerly while the extension's setting allows it. Attachments are owned by the shell tab and survive Settings and session-route changes, enabling agent-initiated browsing while the UI is hidden. Removing an owning shell tab or disabling the setting closes its attachments. A replacement by another Desktop is terminal rather than a reason to reclaim ownership.
|
||||
|
||||
The host resolves an authenticated Node-side client for the current server identity on each attachment attempt. Chromium requests still use the server-side tunnel. Request IDs, cancellation, state acknowledgements, explicit tab targeting, and bounded file transfer retain the existing browser protocol semantics.
|
||||
|
||||
## Exploration notes
|
||||
|
||||
This draft is statically registered as a built-in renderer and main extension. Third-party installation/discovery is a separate loader concern. The new extension-owned setting defaults off; the old host `experimentalBrowser` value is retained in storage, and a migration must be agreed before this becomes a production change. Existing host translation keys are reused byte-for-byte; plugin-owned translation catalogs are an API follow-up.
|
||||
|
||||
## Checks
|
||||
|
||||
- `bun typecheck` and `bun test` run from this package.
|
||||
- `packages/desktop/test/browser-native.test.ts` exercises this package through the generic main extension host, the actual server plugin and real Chromium.
|
||||
- The native suite covers all 44 browser operations, state-publication retries, file bytes crossing separate server/Desktop storage, and replacement handling.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@opencode/plugin-browser-desktop",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./main": "./src/main.ts",
|
||||
"./rpc": "./src/rpc.ts",
|
||||
"./page": "./src/page.ts",
|
||||
"./native/*": "./src/native/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"lighthouse": "13.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"puppeteer-core": "25.9.0"
|
||||
}
|
||||
}
|
||||
+33
-61
@@ -1,18 +1,12 @@
|
||||
import type { BrowserPaneCommand, BrowserPaneLayout, BrowserPaneTarget } from "@opencode/app/desktop"
|
||||
import { NodeHttpClient } from "@effect/platform-node"
|
||||
import type { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import { SessionID } from "@opencode/schema/session-id"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Deferred, Effect, ManagedRuntime, Queue, Schedule, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
|
||||
import { createBrowserPage, type BrowserPage } from "./browser-chromium"
|
||||
import { browserFailure } from "./browser/errors"
|
||||
import { createBrowserNetwork, type BrowserNetwork } from "./browser/network"
|
||||
import { destinationOrigin } from "./browser/policy"
|
||||
import { emitIpcEvent } from "./ipc-events"
|
||||
import { SidecarCredentials } from "./service/sidecar-credentials"
|
||||
import { Deferred, Effect, Layer, ManagedRuntime, Queue, Schedule, Schema, Stream } from "effect"
|
||||
import { createBrowserPage, type BrowserPage } from "./page"
|
||||
import { browserFailure } from "./native/errors"
|
||||
import { createBrowserNetwork, type BrowserNetwork } from "./native/network"
|
||||
import { BrowserDesktop } from "./rpc"
|
||||
|
||||
type Entry = {
|
||||
bindingID: string
|
||||
@@ -20,7 +14,7 @@ type Entry = {
|
||||
abort: AbortController
|
||||
registered: PromiseWithResolvers<void>
|
||||
requests: Map<string, { abort: AbortController; tabID?: Browser.TabID }>
|
||||
report?: (event: BrowserPaneEvent["event"]) => void
|
||||
report?: (event: BrowserDesktop.Event) => void
|
||||
cleanup?: () => void
|
||||
pages: Map<Browser.TabID, BrowserPage>
|
||||
focusedTabID: Browser.TabID | null
|
||||
@@ -29,15 +23,16 @@ type Entry = {
|
||||
network?: BrowserNetwork
|
||||
}
|
||||
|
||||
export function createBrowserPane() {
|
||||
export function createBrowserPane(ctx: MainPlugin.Context) {
|
||||
const entries = new Map<string, Entry>()
|
||||
// Keep long-lived RPC requests off Chromium's shared HTTP connection pool.
|
||||
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
|
||||
const runtime = ManagedRuntime.make(Layer.empty)
|
||||
let disposed = false
|
||||
return {
|
||||
async register(win: BrowserWindow, bindingID: string, target: BrowserPaneTarget) {
|
||||
if (disposed || !destinationOrigin(target.endpoint.url)) throw new Error("browser.pane.registration.invalid")
|
||||
if (target.endpoint.username && !target.endpoint.password) throw new Error("browser.pane.endpoint.invalid")
|
||||
async register(bindingID: string, target: { sessionID: string; serverID: string }, signal?: AbortSignal) {
|
||||
signal?.throwIfAborted()
|
||||
const win = ctx.window
|
||||
if (disposed) throw new Error("browser.pane.registration.invalid")
|
||||
if (entries.has(bindingID)) throw new Error("browser.pane.owner.invalid")
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) throw new Error("browser.pane.owner.unavailable")
|
||||
const sessionID = SessionID.make(target.sessionID)
|
||||
@@ -68,20 +63,7 @@ export function createBrowserPane() {
|
||||
void runtime
|
||||
.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
// The renderer never holds the managed sidecar's password; Node requests bypass
|
||||
// the webRequest header injection, so resolve the credential here in main.
|
||||
const authorization = target.endpoint.password
|
||||
? `Basic ${Buffer.from(`${target.endpoint.username ?? "opencode"}:${target.endpoint.password}`).toString("base64")}`
|
||||
: SidecarCredentials.authorization(SidecarCredentials.get(), target.endpoint.url)
|
||||
const client = yield* OpenCode.make({ baseUrl: target.endpoint.url }).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
authorization
|
||||
? HttpClient.mapRequest(http, HttpClientRequest.setHeader("authorization", authorization))
|
||||
: http,
|
||||
),
|
||||
)
|
||||
const client = yield* Effect.promise(() => ctx.client(target.serverID))
|
||||
const session = yield* client.session.get({ sessionID })
|
||||
const options = {
|
||||
location: { directory: session.location.directory, workspace: session.location.workspaceID },
|
||||
@@ -112,7 +94,7 @@ export function createBrowserPane() {
|
||||
// attachment ends; the results queued behind it then never name a tab the server lacks.
|
||||
// "unavailable" means the server already dropped this attachment, which attach reports.
|
||||
entry.report = (event) => {
|
||||
const local = Effect.sync(() => publish(entry, event))
|
||||
const local = Effect.promise(() => publish(entry, event))
|
||||
if (event.type !== "state") return send(local)
|
||||
send(
|
||||
rpc.state({ ...attachment, state: event.state ?? { tabs: [], focusedTabID: null } }, options).pipe(
|
||||
@@ -214,32 +196,20 @@ export function createBrowserPane() {
|
||||
)
|
||||
.catch(stop)
|
||||
const timeout = setTimeout(stop, 15_000)
|
||||
await entry.registered.promise.finally(() => clearTimeout(timeout))
|
||||
signal?.addEventListener("abort", stop, { once: true })
|
||||
await entry.registered.promise.finally(() => {
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener("abort", stop)
|
||||
})
|
||||
if (entries.get(bindingID) !== entry) throw new Error("browser.pane.registration.closed")
|
||||
publishState(entry)
|
||||
},
|
||||
layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) {
|
||||
const entry = owned(win, bindingID)
|
||||
if (!value) return entry.pages.forEach((page) => page.setVisible(false))
|
||||
const page = entry.pages.get(value.tabID)
|
||||
if (!page) return
|
||||
const bounds = value.bounds
|
||||
if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
page.setVisible(false)
|
||||
return
|
||||
}
|
||||
entry.pages.forEach((other) => {
|
||||
if (other !== page) other.setVisible(false)
|
||||
})
|
||||
page.layout(bounds, value.background, value.radius)
|
||||
page.setVisible(true)
|
||||
async command(bindingID: string, command: Browser.Action, signal = ctx.lifecycle.signal) {
|
||||
const entry = owned(bindingID)
|
||||
await execute(entry, { action: command, files: [] }, signal)
|
||||
},
|
||||
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
|
||||
const entry = owned(win, bindingID)
|
||||
await execute(entry, { action: command, files: [] }, new AbortController().signal)
|
||||
},
|
||||
async close(win: BrowserWindow, bindingID: string) {
|
||||
close(owned(win, bindingID))
|
||||
async close(bindingID: string) {
|
||||
close(owned(bindingID))
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true
|
||||
@@ -248,15 +218,15 @@ export function createBrowserPane() {
|
||||
},
|
||||
}
|
||||
|
||||
function owned(win: BrowserWindow, bindingID: string) {
|
||||
function owned(bindingID: string) {
|
||||
const entry = entries.get(bindingID)
|
||||
if (!entry || entry.win !== win) throw new Error("browser.pane.unavailable")
|
||||
if (!entry || entry.win !== ctx.window) throw new Error("browser.pane.unavailable")
|
||||
return entry
|
||||
}
|
||||
|
||||
function publish(entry: Entry, event: BrowserPaneEvent["event"]) {
|
||||
async function publish(entry: Entry, event: BrowserDesktop.Event) {
|
||||
if (!entries.has(entry.bindingID) || entry.win.isDestroyed() || entry.win.webContents.isDestroyed()) return
|
||||
emitIpcEvent(entry.win.webContents, new BrowserPaneEvent({ bindingID: entry.bindingID, event }))
|
||||
await ctx.emit(BrowserDesktop.Definition, "changed", { bindingID: entry.bindingID, event })
|
||||
}
|
||||
|
||||
function close(entry: Entry, reason = "browser.pane.registration.closed") {
|
||||
@@ -296,6 +266,7 @@ export function createBrowserPane() {
|
||||
const event = {
|
||||
type: "state" as const,
|
||||
state: { tabs: Array.from(entry.pages.values(), (page) => page.state()), focusedTabID: entry.focusedTabID },
|
||||
surfaces: Object.fromEntries(Array.from(entry.pages.values(), (page) => [page.state().id, page.surfaceID])),
|
||||
...(error === undefined ? {} : { error }),
|
||||
}
|
||||
const next = JSON.stringify(event)
|
||||
@@ -304,9 +275,9 @@ export function createBrowserPane() {
|
||||
report(entry, event)
|
||||
}
|
||||
|
||||
function report(entry: Entry, event: BrowserPaneEvent["event"]) {
|
||||
function report(entry: Entry, event: BrowserDesktop.Event) {
|
||||
if (entry.report) return entry.report(event)
|
||||
publish(entry, event)
|
||||
void publish(entry, event).catch(console.error)
|
||||
}
|
||||
|
||||
function create(entry: Entry, initialize = true, popupOptions?: Electron.BrowserWindowConstructorOptions) {
|
||||
@@ -317,6 +288,7 @@ export function createBrowserPane() {
|
||||
}
|
||||
const page = createBrowserPage(entry.win, {
|
||||
id,
|
||||
surfaces: ctx.surfaces,
|
||||
partition: entry.partition,
|
||||
network: entry.network,
|
||||
initialize,
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Plugin } from "@opencode/plugin/desktop"
|
||||
import { Panel, NativeSurface } from "@opencode/plugin/desktop/solid"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Stack, SettingsRow } from "@opencode/ui/layout"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createBrowser } from "./model"
|
||||
import { BrowserToolbar } from "./toolbar"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.browser",
|
||||
name: "session.tab.browser",
|
||||
main: true,
|
||||
setup(ctx) {
|
||||
if (!ctx.app.native) return
|
||||
const [settings, setSettings] = ctx.storage.store("settings", { initial: { enabled: false } })
|
||||
const browser = createBrowser(ctx, () => settings.enabled)
|
||||
ctx.ui.slot({
|
||||
append: "settings.experimental",
|
||||
render: () => (
|
||||
<SettingsRow
|
||||
title={ctx.i18n.t("settings.general.row.browserPane.title")}
|
||||
description={ctx.i18n.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={(enabled) =>
|
||||
setSettings((settings) => {
|
||||
settings.enabled = enabled
|
||||
})
|
||||
}
|
||||
hideLabel
|
||||
>
|
||||
{ctx.i18n.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
),
|
||||
})
|
||||
ctx.ui.slot({
|
||||
append: "session.panel.actions",
|
||||
when: () => {
|
||||
const session = ctx.sessions.current()
|
||||
return !!session && browser.available(session)
|
||||
},
|
||||
render: ({ session }) => (
|
||||
<Menu.Item onSelect={() => browser.open(session)}>
|
||||
<Icon name="window-cursor" size="small" />
|
||||
{ctx.i18n.t("session.tab.browser")}
|
||||
</Menu.Item>
|
||||
),
|
||||
})
|
||||
ctx.ui.slot({
|
||||
append: "session.panel",
|
||||
when: () => settings.enabled,
|
||||
render: ({ session }) => (
|
||||
<For each={browser.tabs(session)}>
|
||||
{(tab) => (
|
||||
<Panel
|
||||
id={tab.id}
|
||||
title={tab.title || ctx.i18n.t("session.tab.browser")}
|
||||
icon={<Icon name="window-cursor" size="small" />}
|
||||
loading={tab.loading}
|
||||
onClose={() => browser.close(session, tab.id)}
|
||||
onSelect={() => browser.focus(session, tab.id)}
|
||||
>
|
||||
<Stack>
|
||||
<BrowserToolbar
|
||||
context={ctx}
|
||||
tab={tab}
|
||||
error={browser.state(session)?.error}
|
||||
command={(action) => browser.command(session, action)}
|
||||
/>
|
||||
<Show when={browser.state(session)?.surfaces[tab.id]}>{(id) => <NativeSurface id={id()} />}</Show>
|
||||
</Stack>
|
||||
</Panel>
|
||||
)}
|
||||
</For>
|
||||
),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MainPlugin } from "@opencode/plugin/desktop/main"
|
||||
import { BrowserDesktop } from "./rpc"
|
||||
import { createBrowserPane } from "./connection"
|
||||
|
||||
export default MainPlugin.define({
|
||||
id: "opencode.browser",
|
||||
rpc: BrowserDesktop.Definition,
|
||||
setup(ctx) {
|
||||
const browser = createBrowserPane(ctx)
|
||||
ctx.lifecycle.own(() => {
|
||||
void browser.dispose().catch(console.error)
|
||||
})
|
||||
return {
|
||||
async register(input, call) {
|
||||
await browser.register(input.bindingID, input, call.signal)
|
||||
return null
|
||||
},
|
||||
async command(input, call) {
|
||||
await browser.command(input.bindingID, input.action, call.signal)
|
||||
return null
|
||||
},
|
||||
async close(input) {
|
||||
await browser.close(input.bindingID)
|
||||
return null
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Context, SessionContext } from "@opencode/plugin/desktop"
|
||||
import { batch, createEffect, getOwner, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { BrowserDesktop } from "./rpc"
|
||||
|
||||
type State = { browser: Browser.State | null; surfaces: Readonly<Record<string, string>>; error?: string }
|
||||
type Live = {
|
||||
session: SessionContext
|
||||
bindingID: string
|
||||
attempts: number
|
||||
retry?: ReturnType<typeof setTimeout>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createBrowser(ctx: Context, enabled: () => boolean) {
|
||||
const rpc = ctx.main.rpc(BrowserDesktop.Definition)
|
||||
const [states, setState] = createStore<Record<string, State | undefined>>({})
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, boolean>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const owner = getOwner()
|
||||
const close = (key: string) => {
|
||||
live.get(key)?.dispose()
|
||||
live.delete(key)
|
||||
setState(key, undefined)
|
||||
}
|
||||
const available = (session: SessionContext) =>
|
||||
enabled() && session.server.compatible && !unsupported[session.server.id]
|
||||
const command = (session: SessionContext, action: Browser.Action) => {
|
||||
const entry = live.get(session.key)
|
||||
if (!entry) return
|
||||
void rpc.command({ bindingID: entry.bindingID, action }).catch(() => {
|
||||
if (live.get(session.key) === entry) setState(session.key, "error", ctx.i18n.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
ctx.lifecycle.own(
|
||||
rpc.events.on("changed", ({ bindingID, event }) => {
|
||||
const entry = Array.from(live.values()).find((entry) => entry.bindingID === bindingID)
|
||||
if (!entry) return
|
||||
const session = entry.session
|
||||
if (event.type === "focus") {
|
||||
ctx.ui.panel.open(event.tabID, session)
|
||||
return
|
||||
}
|
||||
if (event.error === "browser.pane.unsupported") {
|
||||
setUnsupported(session.server.id, true)
|
||||
close(session.key)
|
||||
return
|
||||
}
|
||||
if (event.error === "browser.pane.replaced") {
|
||||
entry.dispose()
|
||||
setState(session.key, { browser: null, surfaces: {}, error: ctx.i18n.t("session.browser.replaced") })
|
||||
return
|
||||
}
|
||||
batch(() => {
|
||||
setState(session.key, "browser", reconcile(event.state))
|
||||
setState(session.key, "surfaces", reconcile(event.surfaces))
|
||||
setState(session.key, "error", event.error ? ctx.i18n.t("common.requestFailed") : undefined)
|
||||
})
|
||||
if (event.state) entry.attempts = 0
|
||||
if (event.error === "browser.pane.registration.closed") {
|
||||
clearTimeout(entry.retry)
|
||||
entry.retry = setTimeout(() => register(entry), Math.min(30_000, 1_000 * 2 ** entry.attempts++))
|
||||
}
|
||||
}),
|
||||
)
|
||||
const register = (entry: Live) => {
|
||||
if (live.get(entry.session.key) !== entry) return
|
||||
// The host resolves the current endpoint and credentials on every attempt.
|
||||
void rpc
|
||||
.register({ bindingID: entry.bindingID, sessionID: entry.session.sessionID, serverID: entry.session.server.id })
|
||||
.catch(() => {})
|
||||
}
|
||||
createEffect(() => {
|
||||
const sessions = ctx.sessions.list()
|
||||
Array.from(live).forEach(([key, entry]) => {
|
||||
if (!sessions.some((session) => session.key === key) || !available(entry.session)) close(key)
|
||||
})
|
||||
sessions.filter(available).forEach((session) => {
|
||||
if (live.has(session.key)) return
|
||||
const entry: Live = { session, bindingID: crypto.randomUUID(), attempts: 0, dispose: () => {} }
|
||||
live.set(session.key, entry)
|
||||
setState(session.key, { browser: null, surfaces: {} })
|
||||
const stop = runWithOwner(owner, () =>
|
||||
session.server.data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === session.sessionID) register(entry)
|
||||
}),
|
||||
)
|
||||
entry.dispose = () => {
|
||||
stop?.()
|
||||
clearTimeout(entry.retry)
|
||||
void rpc.close({ bindingID: entry.bindingID }).catch(() => {})
|
||||
}
|
||||
if (!session.creating) register(entry)
|
||||
})
|
||||
})
|
||||
ctx.lifecycle.own(() => Array.from(live.keys()).forEach(close))
|
||||
return {
|
||||
available,
|
||||
state: (session: SessionContext) => states[session.key],
|
||||
tabs: (session: SessionContext) => states[session.key]?.browser?.tabs ?? [],
|
||||
command,
|
||||
open: (session: SessionContext) => command(session, { type: "tabs.open" }),
|
||||
close: (session: SessionContext, tabID: Browser.TabID) => command(session, { type: "tabs.close", tabID }),
|
||||
focus(session: SessionContext, tabID: Browser.TabID) {
|
||||
if (states[session.key]?.browser?.focusedTabID !== tabID) command(session, { type: "tabs.focus", tabID })
|
||||
},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user