mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-03 14:06:29 +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 |
No files matched your search
@@ -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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user