mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb7415dcc7 | |||
| 643eed300d | |||
| c3a6721de2 | |||
| 47af7462ea | |||
| a76ab93673 | |||
| dfb60e37a3 |
@@ -119,6 +119,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -589,9 +590,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.3",
|
||||
"@opentui/keymap": ">=0.5.3",
|
||||
"@opentui/solid": ">=0.5.3",
|
||||
"@opentui/core": ">=0.5.4",
|
||||
"@opentui/keymap": ">=0.5.4",
|
||||
"@opentui/solid": ">=0.5.4",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1087,9 +1088,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@opentui/core": "0.5.4",
|
||||
"@opentui/keymap": "0.5.4",
|
||||
"@opentui/solid": "0.5.4",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2111,27 +2112,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
|
||||
+3
-3
@@ -47,9 +47,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@opentui/core": "0.5.4",
|
||||
"@opentui/keymap": "0.5.4",
|
||||
"@opentui/solid": "0.5.4",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.route("**/api/session/*/rename", async (route) => {
|
||||
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
|
||||
const session = sessions.find((item) => item.id === sessionID)
|
||||
const payload: unknown = route.request().postDataJSON()
|
||||
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
|
||||
throw new Error("Invalid rename payload")
|
||||
if (session) session.title = payload.title
|
||||
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
|
||||
await expect(row).toBeVisible()
|
||||
const container = page.locator(
|
||||
`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`,
|
||||
)
|
||||
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
|
||||
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
|
||||
|
||||
await row.focus()
|
||||
await row.press("Shift+F10")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
|
||||
await expect(row).toBeFocused()
|
||||
|
||||
const rowBox = await row.boundingBox()
|
||||
await row.click({ button: "right", position: { x: 48, y: 12 } })
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
|
||||
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
|
||||
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
|
||||
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click()
|
||||
const title = page.locator('[data-component="home-session-rename"]')
|
||||
await expect(title).toBeFocused()
|
||||
await expect(title).toHaveValue(fixture.expected.targetTitle)
|
||||
const editorBox = await title.boundingBox()
|
||||
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
expect(editorBox?.x).toBe(titleBox?.x)
|
||||
expect(editingAvatarBox).toEqual(avatarBox)
|
||||
expect(
|
||||
await title.evaluate((element) => ({
|
||||
outline: getComputedStyle(element).outlineStyle,
|
||||
shadow: getComputedStyle(element).boxShadow,
|
||||
})),
|
||||
).toEqual({ outline: "none", shadow: "none" })
|
||||
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
|
||||
await title.fill("Renamed from Home")
|
||||
const renamed = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
|
||||
)
|
||||
await title.press("Enter")
|
||||
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
|
||||
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
|
||||
await expect(renamedRow).toBeVisible()
|
||||
|
||||
await renamedRow.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Home" }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
|
||||
await expect(renamedRow).toBeVisible()
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
const download = page.waitForEvent("download")
|
||||
const exportItem = page.getByRole("menuitem", { name: "Export..." })
|
||||
await exportItem.click()
|
||||
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
|
||||
await expect(exportItem).toBeHidden()
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Delete..." }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
|
||||
const removed = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Delete session" }).click()
|
||||
await removed
|
||||
await expect(renamedRow).toBeHidden()
|
||||
})
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { skipToken, useQuery } from "@tanstack/solid-query"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
@@ -15,7 +19,10 @@ import { ServerConnection } from "@/context/servers"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { removedSessionIDs } from "@/pages/session/session-domain"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { sessionLabel, sessionTitle } from "@/utils/session-title"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
@@ -40,6 +47,8 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const [removed, setRemoved] = createStore({ keys: [] as string[] })
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = home.project.selected()
|
||||
if (!project) return home.project.list().flatMap(directories)
|
||||
@@ -65,9 +74,13 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
})
|
||||
const indexedSessions = createMemo(() => {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return []
|
||||
const conn = home.server.focused()
|
||||
if (!ctx || !conn) return []
|
||||
const server = ServerConnection.key(conn)
|
||||
return retainHomeSessions(
|
||||
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()),
|
||||
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()).filter(
|
||||
(session) => !removed.keys.includes(`${server}\0${session.id}`),
|
||||
),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
)
|
||||
@@ -130,6 +143,112 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
},
|
||||
])
|
||||
|
||||
const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => {
|
||||
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
|
||||
const ctx = conn ? home.server.context(conn) : undefined
|
||||
if (!conn || !ctx) return false
|
||||
const next = title.trim()
|
||||
if (!next || next === sessionLabel(session)) return true
|
||||
return ctx.sdk.api.session
|
||||
.rename({ sessionID: session.id, title: next })
|
||||
.then(() => {
|
||||
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
|
||||
current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)),
|
||||
)
|
||||
return true
|
||||
})
|
||||
.catch((cause) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
})
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => {
|
||||
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
|
||||
const ctx = conn ? home.server.context(conn) : undefined
|
||||
if (!ctx) return
|
||||
try {
|
||||
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (cause) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description:
|
||||
cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (server: ServerConnection.Key, session: SessionInfo) => {
|
||||
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
|
||||
const ctx = conn ? home.server.context(conn) : undefined
|
||||
if (!conn || !ctx) return false
|
||||
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
|
||||
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
|
||||
return ctx.sdk.api.session
|
||||
.remove({ sessionID: session.id })
|
||||
.then(() => {
|
||||
const removedIDs = new Set(ids)
|
||||
setRemoved("keys", (current) => [
|
||||
...new Set([...current, ...ids.map((id) => `${server}\0${id}`)]),
|
||||
])
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
|
||||
current?.filter((item) => !removedIDs.has(item.id)),
|
||||
)
|
||||
notifySessionTabsRemoved({
|
||||
server: ServerConnection.key(conn),
|
||||
directory: session.location.directory,
|
||||
sessionIDs: ids,
|
||||
})
|
||||
return true
|
||||
})
|
||||
.catch((cause) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(cause, language.t("session.delete.failed.title")),
|
||||
})
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) {
|
||||
const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new")
|
||||
const confirm = async () => {
|
||||
await remove(props.server, props.session)
|
||||
dialog.close()
|
||||
}
|
||||
return (
|
||||
<DialogV2 fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", { name: name() })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={confirm}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</DialogV2>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
copy: {
|
||||
language,
|
||||
@@ -190,6 +309,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
}),
|
||||
})
|
||||
},
|
||||
rename,
|
||||
export: exportSession,
|
||||
showDelete: (server: ServerConnection.Key, session: SessionInfo) =>
|
||||
dialog.show(() => <DeleteDialog server={server} session={session} />),
|
||||
},
|
||||
tab: {
|
||||
isOpen: (record: HomeSessionRecord) =>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo, For, Show, Suspense } from "solid-js"
|
||||
import { createMemo, For, onCleanup, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
|
||||
const HOME_SESSION_LONG_PRESS_MS = 500
|
||||
|
||||
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
||||
// tab in the background without navigating, matching browser conventions.
|
||||
@@ -54,6 +58,9 @@ export type HomeSessionsViewProps = {
|
||||
onCreateSession: () => void
|
||||
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
|
||||
onArchiveSession: (session: SessionInfo) => Promise<void>
|
||||
onRenameSession: (server: ServerConnection.Key, session: SessionInfo, title: string) => Promise<boolean>
|
||||
onExportSession: (server: ServerConnection.Key, session: SessionInfo) => Promise<void>
|
||||
onDeleteSession: (server: ServerConnection.Key, session: SessionInfo) => void
|
||||
onSetHoverTarget: (element: HTMLElement) => void
|
||||
onSetThumbTrack: (element: HTMLDivElement) => void
|
||||
onSetContent: (element: HTMLDivElement) => void
|
||||
@@ -415,42 +422,238 @@ function HomeSessionGroupHeader(props: {
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
const [state, setState] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
menuX: 0,
|
||||
menuY: 0,
|
||||
menuOpen: false,
|
||||
pendingAction: undefined as "rename" | "export" | "delete" | undefined,
|
||||
renaming: false,
|
||||
})
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
let rowRef: HTMLButtonElement | undefined
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let longPressStart: { x: number; y: number } | undefined
|
||||
let suppressClick = false
|
||||
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimer !== undefined) clearTimeout(longPressTimer)
|
||||
longPressTimer = undefined
|
||||
longPressStart = undefined
|
||||
}
|
||||
const finishLongPress = () => {
|
||||
clearLongPress()
|
||||
if (!suppressClick) return
|
||||
setTimeout(() => {
|
||||
suppressClick = false
|
||||
})
|
||||
}
|
||||
onCleanup(clearLongPress)
|
||||
|
||||
const openMenu = (element: HTMLElement, clientX: number, clientY: number) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
setState({ menuX: clientX - bounds.left, menuY: clientY - bounds.top, menuOpen: true })
|
||||
}
|
||||
|
||||
const openEditor = () => {
|
||||
setState({ draft: title(), editing: true })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
const closeEditor = () => {
|
||||
if (state.renaming) return
|
||||
setState("editing", false)
|
||||
}
|
||||
const saveEditor = async () => {
|
||||
if (state.renaming) return
|
||||
setState("renaming", true)
|
||||
const saved = await props.onRenameSession(props.server, props.record.session, state.draft)
|
||||
setState("renaming", false)
|
||||
if (saved) setState("editing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||
data-component="home-session-row-container"
|
||||
data-session-id={props.record.session.id}
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
if (state.editing) return
|
||||
openMenu(event.currentTarget, event.clientX, event.clientY)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`
|
||||
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
|
||||
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
|
||||
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onOpenSession(props.record.session, { background: true })
|
||||
}}
|
||||
<Show
|
||||
when={!state.editing}
|
||||
fallback={
|
||||
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={false}
|
||||
/>
|
||||
<InlineInput
|
||||
ref={(element) => {
|
||||
titleRef = element
|
||||
}}
|
||||
data-component="home-session-rename"
|
||||
dir="auto"
|
||||
value={state.draft}
|
||||
disabled={state.renaming}
|
||||
class={`
|
||||
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
|
||||
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
|
||||
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
|
||||
`}
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => setState("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
<button
|
||||
ref={(element) => {
|
||||
rowRef = element
|
||||
}}
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={state.menuOpen}
|
||||
class={`
|
||||
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
|
||||
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
|
||||
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") return
|
||||
clearLongPress()
|
||||
const element = event.currentTarget
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
longPressStart = { x, y }
|
||||
longPressTimer = setTimeout(() => {
|
||||
suppressClick = true
|
||||
clearLongPress()
|
||||
openMenu(element, x, y)
|
||||
}, HOME_SESSION_LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!longPressStart) return
|
||||
if (Math.abs(event.clientX - longPressStart.x) <= 8 && Math.abs(event.clientY - longPressStart.y) <= 8)
|
||||
return
|
||||
clearLongPress()
|
||||
}}
|
||||
onPointerUp={finishLongPress}
|
||||
onPointerCancel={() => {
|
||||
clearLongPress()
|
||||
suppressClick = false
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
|
||||
event.preventDefault()
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
openMenu(event.currentTarget, bounds.left + 12, bounds.bottom)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (suppressClick) {
|
||||
suppressClick = false
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })
|
||||
}}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onOpenSession(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<MenuV2
|
||||
modal={false}
|
||||
placement="bottom-start"
|
||||
gutter={2}
|
||||
open={state.menuOpen}
|
||||
onOpenChange={(open) => setState("menuOpen", open)}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as="span"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
class="pointer-events-none absolute size-px"
|
||||
style={{ left: `${state.menuX}px`, top: `${state.menuY}px` }}
|
||||
/>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</button>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const action = state.pendingAction
|
||||
if (!action) {
|
||||
requestAnimationFrame(() => rowRef?.focus())
|
||||
return
|
||||
}
|
||||
setState("pendingAction", undefined)
|
||||
if (action === "rename") {
|
||||
openEditor()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (action === "export") {
|
||||
void props.onExportSession(props.server, props.record.session)
|
||||
return
|
||||
}
|
||||
props.onDeleteSession(props.server, props.record.session)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<MenuV2.Item onSelect={() => setState({ pendingAction: "rename", menuOpen: false })}>
|
||||
{props.language.t("common.rename")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => setState({ pendingAction: "export", menuOpen: false })}>
|
||||
{props.language.t("common.export")}...
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => setState({ pendingAction: "delete", menuOpen: false })}>
|
||||
{props.language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||
<div
|
||||
class={`
|
||||
@@ -481,6 +684,7 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
|
||||
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
data-component="home-session-title"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
|
||||
classList={{
|
||||
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
|
||||
|
||||
@@ -27,6 +27,9 @@ export function HomeSessions(props: {
|
||||
onCreateSession={props.sessions.session.create}
|
||||
onOpenSession={props.sessions.session.open}
|
||||
onArchiveSession={props.sessions.session.archive}
|
||||
onRenameSession={props.sessions.session.rename}
|
||||
onExportSession={props.sessions.session.export}
|
||||
onDeleteSession={props.sessions.session.showDelete}
|
||||
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
|
||||
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
|
||||
onSetContent={props.scroll.header.setContent}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { removedSessionIDs } from "./session-domain"
|
||||
|
||||
describe("removedSessionIDs", () => {
|
||||
test("includes all descendants without unrelated sessions", () => {
|
||||
const sessions = [
|
||||
{ id: "root" },
|
||||
{ id: "child", parentID: "root" },
|
||||
{ id: "grandchild", parentID: "child" },
|
||||
{ id: "other" },
|
||||
]
|
||||
|
||||
expect([...removedSessionIDs(sessions, "root")]).toEqual(["root", "child", "grandchild"])
|
||||
})
|
||||
})
|
||||
@@ -17,3 +17,19 @@ export function selectVisibleSessionUserMessages(messages: SessionMessageUser[],
|
||||
if (!revertMessageID) return messages
|
||||
return messages.filter((message) => message.id < revertMessageID)
|
||||
}
|
||||
|
||||
export function removedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
|
||||
const removed = new Set([sessionID])
|
||||
const byParent = Map.groupBy(
|
||||
sessions.filter((session) => session.parentID),
|
||||
(session) => session.parentID!,
|
||||
)
|
||||
const visit = (id: string) =>
|
||||
byParent.get(id)?.forEach((child) => {
|
||||
if (removed.has(child.id)) return
|
||||
removed.add(child.id)
|
||||
visit(child.id)
|
||||
})
|
||||
visit(sessionID)
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -22,19 +22,3 @@ export function timelineChildTitle(input: {
|
||||
if (input.taskDescription) return input.taskDescription
|
||||
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
|
||||
}
|
||||
|
||||
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
|
||||
const removed = new Set([sessionID])
|
||||
const byParent = Map.groupBy(
|
||||
sessions.filter((session) => session.parentID),
|
||||
(session) => session.parentID!,
|
||||
)
|
||||
const visit = (id: string) =>
|
||||
byParent.get(id)?.forEach((child) => {
|
||||
if (removed.has(child.id)) return
|
||||
removed.add(child.id)
|
||||
visit(child.id)
|
||||
})
|
||||
visit(sessionID)
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ import { useSettings } from "@/context/settings"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import type { SessionController } from "@/pages/session/session-controller"
|
||||
import { removedSessionIDs } from "@/pages/session/session-domain"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
|
||||
import { timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
@@ -171,7 +172,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
return false
|
||||
})
|
||||
if (!success) return false
|
||||
const removed = timelineRemovedSessionIDs(data.session.list(), id)
|
||||
const removed = removedSessionIDs(data.session.list(), id)
|
||||
void navigateAfterRemoval(id, session.parentID, next?.id)
|
||||
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Manage authentication",
|
||||
description: "manage AI providers and credentials",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to a well-known authentication provider",
|
||||
Spec.make("list", {
|
||||
description: "list providers and credentials",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
|
||||
...ServerParams,
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("login", {
|
||||
description: "log in to a provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID, name, or well-known provider URL"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "log out from a configured provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -145,11 +170,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to export to stdout"),
|
||||
Flag.optional,
|
||||
),
|
||||
session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
|
||||
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
|
||||
const skip = Symbol("skip")
|
||||
const custom = Symbol("custom")
|
||||
|
||||
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
|
||||
if (!fields) return undefined
|
||||
yield* requireInteractive("Authentication form input requires an interactive terminal")
|
||||
const answer: FormAnswer = {}
|
||||
for (const field of fields) {
|
||||
if (!active(field, answer)) continue
|
||||
const value = yield* answerField(field)
|
||||
if (value !== undefined) answer[field.key] = value
|
||||
}
|
||||
return answer
|
||||
})
|
||||
|
||||
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
|
||||
yield* requireInteractive("API key input requires an interactive terminal")
|
||||
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
|
||||
})
|
||||
|
||||
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
|
||||
const message = field.title ?? field.key
|
||||
if (field.description) log.info(field.description)
|
||||
if (field.type === "external") {
|
||||
log.info(field.url)
|
||||
yield* openUrl(field.url)
|
||||
const acknowledged = yield* prompt<boolean>(() =>
|
||||
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
|
||||
)
|
||||
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
|
||||
return true
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
|
||||
const options: Array<Option<boolean | typeof skip>> = [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
{ value: skip, label: "Skip" },
|
||||
]
|
||||
const value = yield* prompt<boolean | typeof skip>(() =>
|
||||
select<boolean | typeof skip>({
|
||||
message,
|
||||
options,
|
||||
initialValue: field.default ?? skip,
|
||||
}),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
return value
|
||||
}
|
||||
if (field.type === "multiselect") {
|
||||
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type another value" })
|
||||
const values = yield* prompt<Array<string | typeof custom>>(() =>
|
||||
multiselect<string | typeof custom>({
|
||||
message,
|
||||
options,
|
||||
initialValues: field.default,
|
||||
required: field.required || (field.minItems ?? 0) > 0,
|
||||
}),
|
||||
)
|
||||
const selected = values.filter((value): value is string => value !== custom)
|
||||
if (values.includes(custom)) {
|
||||
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
|
||||
}
|
||||
if (field.minItems !== undefined && selected.length < field.minItems) {
|
||||
return yield* Effect.fail(new Error(`Select at least ${field.minItems}`))
|
||||
}
|
||||
if (field.maxItems !== undefined && selected.length > field.maxItems) {
|
||||
return yield* Effect.fail(new Error(`Select at most ${field.maxItems}`))
|
||||
}
|
||||
return selected
|
||||
}
|
||||
if (field.type === "string" && field.options) {
|
||||
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
|
||||
if (!field.required) options.push({ value: skip, label: "Skip" })
|
||||
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
|
||||
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
if (value !== custom) return value
|
||||
}
|
||||
const value = yield* prompt<string>(() =>
|
||||
text({
|
||||
message,
|
||||
placeholder: field.type === "string" ? field.placeholder : undefined,
|
||||
initialValue: field.default === undefined ? undefined : String(field.default),
|
||||
validate: (input) => validateText(field, input),
|
||||
}),
|
||||
)
|
||||
if (!value && !field.required) return undefined
|
||||
if (field.type === "string") return value
|
||||
return Number(value)
|
||||
})
|
||||
|
||||
function active(field: FormField, answer: FormAnswer) {
|
||||
if (field.type === "external" || !field.when) return true
|
||||
return field.when.every((condition) => {
|
||||
const value = answer[condition.key]
|
||||
if (value === undefined) return false
|
||||
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
|
||||
return condition.op === "eq" ? matches : !matches
|
||||
})
|
||||
}
|
||||
|
||||
function required(value: string | undefined) {
|
||||
return value ? undefined : "Required"
|
||||
}
|
||||
|
||||
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
|
||||
if (!value) return field.required ? "Required" : undefined
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return undefined
|
||||
}
|
||||
if (field.minLength !== undefined && value.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
|
||||
} catch {
|
||||
return "Invalid format"
|
||||
}
|
||||
}
|
||||
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||
if (field.format === "date") {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
|
||||
}
|
||||
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, loadIntegrations } from "./shared"
|
||||
import { errorMessage } from "../../../ui/prompt"
|
||||
|
||||
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
|
||||
list(input).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const list = Effect.fn("cli.auth.list")(function* (input) {
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
|
||||
if (input.format === "json") {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
integrations.map((integration) => ({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
connections: integration.connections,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
return
|
||||
}
|
||||
const rows = integrations.flatMap((integration) =>
|
||||
integration.connections.map((connection) => ({
|
||||
integration: integration.name,
|
||||
source: connection.type === "credential" ? connection.label : connection.name,
|
||||
type: connection.type === "credential" ? "stored" : "environment",
|
||||
})),
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No authenticated integrations" + EOL)
|
||||
return
|
||||
}
|
||||
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
|
||||
process.stdout.write(
|
||||
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
|
||||
)
|
||||
})
|
||||
@@ -1,52 +1,267 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm, secret } from "./form"
|
||||
import {
|
||||
createClient,
|
||||
connectMethods,
|
||||
loadIntegrations,
|
||||
location,
|
||||
request,
|
||||
resolveIntegration,
|
||||
resolveMethod,
|
||||
type ConnectMethod,
|
||||
} from "./shared"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
const integrationPriority = new Map([
|
||||
["opencode", 0],
|
||||
["opencode-go", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
["anthropic", 5],
|
||||
["openrouter", 6],
|
||||
["vercel", 7],
|
||||
])
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.login,
|
||||
Effect.fn("cli.auth.login")(function* (input) {
|
||||
process.stdout.write("Logging in..." + EOL + EOL)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
|
||||
const integrationID = input.url.replace(/\/+$/, "")
|
||||
const started = yield* request(() =>
|
||||
client.integration.command.connect({ integrationID, methodID: "login", location }),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const status = yield* wait(client, integrationID, started.data.attemptID)
|
||||
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
|
||||
process.stdout.write("Logged in" + EOL)
|
||||
}),
|
||||
Effect.fn("cli.auth.login")((input) =>
|
||||
login({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
method: Option.getOrUndefined(input.method),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const wait = (
|
||||
const login = Effect.fn("cli.auth.login.run")(function* (input: {
|
||||
target?: string
|
||||
method?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Connect an integration")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integration = yield* findIntegration(client, input.target)
|
||||
const methods = connectMethods(integration)
|
||||
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
|
||||
const method = yield* chooseMethod(methods, input.method)
|
||||
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
|
||||
yield* authenticate(client, integration, method, answer)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
|
||||
if (target && URL.canParse(target)) {
|
||||
const protocol = new URL(target).protocol
|
||||
if (protocol === "http:" || protocol === "https:") {
|
||||
const progress = spinner()
|
||||
progress.start("Discovering authentication provider...")
|
||||
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
|
||||
)
|
||||
}
|
||||
}
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const available = integrations
|
||||
.filter((integration) => connectMethods(integration).length > 0)
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
(integrationPriority.get(a.id) ?? integrationPriority.size) -
|
||||
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: available.map((integration) => {
|
||||
const option = { value: integration.id, label: integration.name, hint: integration.id }
|
||||
if (integration.connections.length > 0) return { ...option, hint: "connected" }
|
||||
if (integration.id === "opencode") return { ...option, hint: "recommended" }
|
||||
return option
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(available, id)
|
||||
})
|
||||
|
||||
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
|
||||
if (target) return yield* resolveMethod(methods, target)
|
||||
if (methods.length === 1) return methods[0]
|
||||
yield* requireInteractive("Pass --method when running without an interactive terminal")
|
||||
const id = yield* prompt<string>(() =>
|
||||
select({
|
||||
message: "Select login method",
|
||||
options: methods.map((method) => {
|
||||
if (method.type === "key") return { value: "key", label: method.label ?? "API key" }
|
||||
return { value: method.id, label: method.label }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveMethod(methods, id)
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: ConnectMethod,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
|
||||
if (method.type === "command") return yield* commandLogin(client, integration, method)
|
||||
return yield* oauthLogin(client, integration, method, answer)
|
||||
})
|
||||
|
||||
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "key" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
|
||||
const progress = spinner()
|
||||
progress.start("Saving credential...")
|
||||
yield* request((signal) =>
|
||||
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
|
||||
)
|
||||
})
|
||||
|
||||
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "oauth" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authorization...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.oauth.connect(
|
||||
{ integrationID: integration.id, methodID: method.id, answer, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
const attempt = started.data
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.oauth.cancel(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
progress.stop("Authorization started")
|
||||
log.info(attempt.instructions)
|
||||
log.info(attempt.url)
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
|
||||
|
||||
if (attempt.mode === "code") {
|
||||
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
|
||||
const code = yield* prompt<string>(() =>
|
||||
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
|
||||
)
|
||||
const completing = spinner()
|
||||
completing.start("Completing authorization...")
|
||||
yield* request((signal) =>
|
||||
client.integration.oauth.complete(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const waiting = spinner()
|
||||
waiting.start("Waiting for authorization...")
|
||||
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
|
||||
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
|
||||
)
|
||||
if (status.status === "complete") {
|
||||
waiting.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
waiting.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authorization expired"))
|
||||
})
|
||||
|
||||
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "command" }>,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authentication command...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel(
|
||||
{
|
||||
integrationID: integration.id,
|
||||
attemptID: started.data.attemptID,
|
||||
location,
|
||||
},
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
|
||||
progress.message(message.trim() || "Waiting for authentication command..."),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
if (status.status === "complete") {
|
||||
progress.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
progress.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authentication expired"))
|
||||
})
|
||||
|
||||
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
shown = false,
|
||||
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
const output = response.data.message?.trim()
|
||||
if (!shown && output) process.stdout.write(output + EOL + EOL)
|
||||
yield* Effect.sleep(500)
|
||||
return yield* wait(client, integrationID, attemptID, shown || !!output)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function request<A>(task: () => Promise<A>) {
|
||||
return Effect.tryPromise({ try: task, catch: (cause) => cause })
|
||||
}
|
||||
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
update: (message: string) => void,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
if (response.data.message) update(response.data.message)
|
||||
yield* Effect.sleep(500)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.logout,
|
||||
Effect.fn("cli.auth.logout")((input) =>
|
||||
logout({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
|
||||
target?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Remove credential")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const integration = yield* chooseIntegration(integrations, input.target)
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
const environment = integration.connections
|
||||
.filter((connection) => connection.type === "env")
|
||||
.map((connection) => connection.name)
|
||||
if (environment.length) {
|
||||
yield* Effect.fail(
|
||||
new Error(
|
||||
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
|
||||
}
|
||||
const progress = spinner()
|
||||
progress.start("Removing credential...")
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) =>
|
||||
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target?: string,
|
||||
) {
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const configured = integrations.filter((integration) =>
|
||||
integration.connections.some((connection) => connection.type === "credential"),
|
||||
)
|
||||
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: configured.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.name,
|
||||
hint: integration.connections
|
||||
.filter((connection) => connection.type === "credential")
|
||||
.map((connection) => connection.label)
|
||||
.join(", "),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(configured, id)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export const location = { directory: process.cwd() }
|
||||
|
||||
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
|
||||
const server = yield* ServerConnection.resolve(input)
|
||||
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
|
||||
})
|
||||
|
||||
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({ try: run, catch: (cause) => cause })
|
||||
}
|
||||
|
||||
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
|
||||
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
|
||||
yield* request((signal) => client.model.default({ location }, { signal }))
|
||||
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
|
||||
Effect.map((response) => response.data),
|
||||
)
|
||||
})
|
||||
|
||||
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target: string,
|
||||
) {
|
||||
const normalized = target.replace(/\/+$/, "")
|
||||
const byID = integrations.find((integration) => integration.id === normalized)
|
||||
if (byID) return byID
|
||||
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
|
||||
})
|
||||
|
||||
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
|
||||
|
||||
export function connectMethods(integration: IntegrationInfo) {
|
||||
return integration.methods
|
||||
.filter((method): method is ConnectMethod => method.type !== "env")
|
||||
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
|
||||
}
|
||||
|
||||
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
|
||||
const normalized = target.toLowerCase()
|
||||
const matches = methods.filter((method) => {
|
||||
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
|
||||
return method.id === target || method.label.toLowerCase() === normalized
|
||||
})
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
|
||||
const available = methods.map((method) => (method.type === "key" ? "key" : method.id)).join(", ")
|
||||
return yield* Effect.fail(
|
||||
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
|
||||
)
|
||||
})
|
||||
@@ -1,140 +1,88 @@
|
||||
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
|
||||
import { autocomplete, cancel, intro, isCancel, log, outro } from "@clack/prompts"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { emitKeypressEvents, type Key } from "node:readline"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { errorMessage } from "../../ui/prompt"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.export,
|
||||
Effect.fn("cli.export")(function* (input) {
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
const selected = requested
|
||||
? undefined
|
||||
: yield* Effect.promise(async () => {
|
||||
const location = await client.location.get({ location: { directory: process.cwd() } })
|
||||
const page = await client.session.list({
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
Effect.fn("cli.export")((input) =>
|
||||
Effect.gen(function* () {
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
if (!requested && !process.stdin.isTTY) {
|
||||
yield* Effect.fail(new Error("Pass a session ID when running without an interactive terminal"))
|
||||
}
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const sessionID = requested
|
||||
? requested
|
||||
: yield* Effect.gen(function* () {
|
||||
intro("Export session", { output: process.stderr })
|
||||
const location = yield* Effect.tryPromise({
|
||||
try: () => client.location.get({ location: { directory: process.cwd() } }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
const page = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.session.list({
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
if (page.data.length === 0) {
|
||||
log.error("No sessions found", { output: process.stderr })
|
||||
outro("Done", { output: process.stderr })
|
||||
return undefined
|
||||
}
|
||||
const selected = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: page.data.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} - ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
if (isCancel(selected)) {
|
||||
cancel("Cancelled", { output: process.stderr })
|
||||
process.exitCode = 130
|
||||
return undefined
|
||||
}
|
||||
outro("Exporting session...", { output: process.stderr })
|
||||
return selected
|
||||
})
|
||||
if (page.data.length === 0) {
|
||||
process.stderr.write(`No sessions found${EOL}`)
|
||||
return undefined
|
||||
}
|
||||
return selectSession(page.data, input.sanitize)
|
||||
})
|
||||
const sessionID = requested ?? selected?.session.id
|
||||
if (!sessionID) return
|
||||
const data = yield* Effect.promise(() =>
|
||||
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
|
||||
)
|
||||
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
|
||||
}),
|
||||
if (!sessionID) return
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => client.session.export({ sessionID, sanitize: input.sanitize }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + EOL)
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
type Selection = { session: SessionInfo; sanitize: boolean }
|
||||
|
||||
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
|
||||
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
|
||||
const input = process.stdin
|
||||
const output = process.stderr
|
||||
const wasRaw = input.isRaw
|
||||
const wasPaused = input.isPaused()
|
||||
const date = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
const columns = output.columns ?? 100
|
||||
const titleWidth = Math.max(8, Math.min(48, columns - 34))
|
||||
let selected = 0
|
||||
let offset = 0
|
||||
let sanitize = initialSanitize
|
||||
let height = 0
|
||||
|
||||
const render = () => {
|
||||
const visible = sessions.slice(offset, offset + 10)
|
||||
const lines = [" \x1b[36mExport session\x1b[0m", ""]
|
||||
lines.push(
|
||||
...visible.map((session) => {
|
||||
const index = sessions.indexOf(session)
|
||||
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
|
||||
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
|
||||
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
|
||||
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
|
||||
}),
|
||||
"",
|
||||
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
|
||||
"",
|
||||
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
|
||||
)
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write(lines.join(EOL) + EOL)
|
||||
height = lines.length
|
||||
}
|
||||
const clear = () => {
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write("\x1b[?25h")
|
||||
input.removeListener("keypress", onKeypress)
|
||||
input.setRawMode(wasRaw ?? false)
|
||||
if (wasPaused) input.pause()
|
||||
}
|
||||
const onKeypress = (value: string | undefined, key: Key) => {
|
||||
if (key.name === "up") {
|
||||
selected = (selected - 1 + sessions.length) % sessions.length
|
||||
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
|
||||
if (selected < offset) offset = selected
|
||||
}
|
||||
if (key.name === "down") {
|
||||
selected = (selected + 1) % sessions.length
|
||||
if (selected === 0) offset = 0
|
||||
if (selected >= offset + 10) offset = selected - 9
|
||||
}
|
||||
if (key.name === "space" || value === " ") sanitize = !sanitize
|
||||
if (key.name === "return") return finish(sessions[selected])
|
||||
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
|
||||
render()
|
||||
}
|
||||
const finish = (session: SessionInfo) => {
|
||||
clear()
|
||||
resolveSelection?.({ session, sanitize })
|
||||
}
|
||||
const cancel = () => {
|
||||
clear()
|
||||
resolveSelection?.()
|
||||
}
|
||||
let resolveSelection: ((selection?: Selection) => void) | undefined
|
||||
|
||||
emitKeypressEvents(input)
|
||||
input.setRawMode(true)
|
||||
input.resume()
|
||||
input.on("keypress", onKeypress)
|
||||
output.write("\x1b[?25l")
|
||||
render()
|
||||
return new Promise<Selection | undefined>((resolve) => {
|
||||
resolveSelection = resolve
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
|
||||
const json = JSON.stringify(data, null, 2) + EOL
|
||||
if (stdout) return json
|
||||
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
|
||||
await Bun.write(file, json)
|
||||
return file + EOL
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
acp: () => import("./commands/handlers/acp"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
auth: {
|
||||
list: () => import("./commands/handlers/auth/list"),
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
logout: () => import("./commands/handlers/auth/logout"),
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
|
||||
@@ -194,6 +194,13 @@ const register = Effect.fnUntraced(function* (
|
||||
yield* current.pipe(
|
||||
Effect.filterOrFail(owns),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.tapError(() =>
|
||||
Effect.logWarning("managed service registration lost; shutting down", {
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
}),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.andThen(shutdown),
|
||||
Effect.forkScoped,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cancel, isCancel, log, outro } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const cancelled = Symbol("cancelled")
|
||||
|
||||
export function prompt<A>(run: () => Promise<A | symbol>) {
|
||||
return Effect.tryPromise({ try: run, catch: (cause) => cause }).pipe(
|
||||
Effect.flatMap((value) => (isCancel(value) ? Effect.fail(cancelled) : Effect.succeed(value))),
|
||||
)
|
||||
}
|
||||
|
||||
export function requireInteractive(message: string) {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) return Effect.void
|
||||
return Effect.fail(new Error(message))
|
||||
}
|
||||
|
||||
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
|
||||
const { default: open } = yield* Effect.promise(() => import("open"))
|
||||
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return effect.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error === cancelled,
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
cancel("Cancelled")
|
||||
process.exitCode = 130
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error(errorMessage(error))
|
||||
outro("Failed")
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -1,20 +1,267 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
|
||||
describe("auth command", () => {
|
||||
test("registers login", async () => {
|
||||
const [auth, login] = await Promise.all([cli(["auth", "--help"]), cli(["auth", "login", "--help"])])
|
||||
test("registers authentication commands", async () => {
|
||||
const [auth, list, login, logout] = await Promise.all([
|
||||
cli(["auth", "--help"]),
|
||||
cli(["auth", "list", "--help"]),
|
||||
cli(["auth", "login", "--help"]),
|
||||
cli(["auth", "logout", "--help"]),
|
||||
])
|
||||
|
||||
expect(auth.exitCode).toBe(0)
|
||||
expect(auth.stdout).toContain("list")
|
||||
expect(auth.stdout).toContain("login")
|
||||
expect(auth.stdout).toContain("Log in to a well-known authentication provider")
|
||||
expect(auth.stdout).toContain("logout")
|
||||
expect(auth.stdout).toContain("manage AI providers and credentials")
|
||||
expect(auth.stdout).toContain("list providers and credentials")
|
||||
expect(auth.stdout).toContain("log in to a provider")
|
||||
expect(auth.stdout).toContain("log out from a configured provider")
|
||||
expect(auth.stdout).not.toContain("connect")
|
||||
expect(list.exitCode).toBe(0)
|
||||
expect(list.stdout).toContain("opencode auth list [flags]")
|
||||
expect(list.stdout).toContain("--format")
|
||||
expect(login.exitCode).toBe(0)
|
||||
expect(login.stdout).toContain("opencode auth login [flags] <url>")
|
||||
expect(login.stdout).toContain("Well-known provider URL")
|
||||
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
|
||||
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
|
||||
expect(login.stdout).toContain("--method")
|
||||
expect(logout.exitCode).toBe(0)
|
||||
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>]")
|
||||
})
|
||||
|
||||
test("lists stored and environment connections", async () => {
|
||||
const requests: string[] = []
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
{ id: "openai", name: "OpenAI", methods: [], connections: [] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
}, requests)
|
||||
|
||||
const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(JSON.parse(result.stdout)).toEqual([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
|
||||
})
|
||||
|
||||
test("runs command authentication without interactive input", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "company",
|
||||
name: "Company",
|
||||
methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") {
|
||||
return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") {
|
||||
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "company", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("Connected to Company")
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" })
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" })
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
|
||||
})
|
||||
|
||||
test("completes automatic OAuth authentication", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
||||
return Response.json(
|
||||
located({
|
||||
attemptID: "con_oauth",
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Authorize OpenAI",
|
||||
mode: "auto",
|
||||
time: { created: 1, expires: 2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
||||
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("https://example.com/authorize")
|
||||
expect(result.stdout).toContain("Connected to OpenAI")
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
||||
})
|
||||
|
||||
test("settles the OAuth spinner when status polling fails", async () => {
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
||||
return Response.json(
|
||||
located({
|
||||
attemptID: "con_oauth",
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Authorize OpenAI",
|
||||
mode: "auto",
|
||||
time: { created: 1, expires: 2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
||||
return new Response("Unavailable", { status: 500 })
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Authentication failed")
|
||||
expect(result.stdout).toContain("Failed")
|
||||
expect(result.stdout).not.toContain("\n at ")
|
||||
})
|
||||
|
||||
test("removes stored credentials", async () => {
|
||||
const removed: string[] = []
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_test", label: "default" }],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") {
|
||||
removed.push("cred_test")
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("Disconnected from Anthropic")
|
||||
expect(removed).toEqual(["cred_test"])
|
||||
})
|
||||
|
||||
test("requires a target outside an interactive terminal", async () => {
|
||||
const result = await cli(["auth", "login"])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Pass an integration ID or name")
|
||||
expect(result.stdout).not.toContain("Background service failed to start")
|
||||
})
|
||||
|
||||
test("reports list connection failures without a stack trace", async () => {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
|
||||
const result = await cli(["auth", "list", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toBe("")
|
||||
expect(result.stderr).toContain("did not provide a compatible V2 health response")
|
||||
expect(result.stderr).not.toContain("\n at ")
|
||||
})
|
||||
})
|
||||
|
||||
function authServer(fetch: (request: Request, url: URL) => Response | Promise<Response>, requests?: string[]) {
|
||||
return Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests?.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === "/api/model/default") return Response.json(located(null))
|
||||
return fetch(request, url)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function health() {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
|
||||
function located<T>(data: T) {
|
||||
return {
|
||||
location: {
|
||||
directory: process.cwd(),
|
||||
project: { id: "project", directory: process.cwd(), canonical: process.cwd() },
|
||||
},
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { writeExport } from "../src/commands/handlers/export"
|
||||
|
||||
const info = {
|
||||
id: "ses_export_test",
|
||||
@@ -72,7 +71,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
|
||||
const [stdout, , exitCode] = await run(["export", info.id, "--server", server.url.toString()])
|
||||
const exported = JSON.parse(stdout)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
@@ -80,7 +79,6 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
|
||||
const [sanitized, , sanitizedExitCode] = await run([
|
||||
"export",
|
||||
"-s",
|
||||
info.id,
|
||||
"--sanitize",
|
||||
"--server",
|
||||
@@ -94,7 +92,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("export reports an empty session list without a stack trace", async () => {
|
||||
test("export requires a session outside an interactive terminal", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
@@ -114,23 +112,39 @@ test("export reports an empty session list without a stack trace", async () => {
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).toBe("")
|
||||
expect(stderr).toBe(`No sessions found${os.EOL}`)
|
||||
expect(stderr).toBe(`Pass a session ID when running without an interactive terminal${os.EOL}`)
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("interactive export writes a temporary JSON file", async () => {
|
||||
const output = await writeExport(transfer, info.id, false)
|
||||
const file = output.trim()
|
||||
test("export reports a missing session without a stack trace", async () => {
|
||||
const sessionID = "ses_missing"
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === `/api/session/${sessionID}/export`) {
|
||||
return Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(path.dirname(file)).toBe(os.tmpdir())
|
||||
expect(await Bun.file(file).json()).toEqual(transfer)
|
||||
const [stdout, stderr, exitCode] = await run(["export", sessionID, "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).toBe("")
|
||||
expect(stderr).toBe(`Session not found: ${sessionID}${os.EOL}`)
|
||||
} finally {
|
||||
await fs.rm(file, { force: true })
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app } from "electron"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import type { ServerReadyData } from "../shared/ipc-contract"
|
||||
import { checkAppExists, resolveAppPath } from "./files/apps"
|
||||
import { registerIpcHandlers } from "./ipc"
|
||||
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
|
||||
import {
|
||||
acquireApplicationLock,
|
||||
configureApplication,
|
||||
@@ -21,7 +21,6 @@ import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-setti
|
||||
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
|
||||
import { startWsl } from "./wsl/start"
|
||||
import { createDeferredWslIpc } from "./wsl/ipc"
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
const logger = configureApplication()
|
||||
@@ -34,8 +33,6 @@ const main = Effect.gen(function* () {
|
||||
yield* prepareDesktop(logger)
|
||||
|
||||
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
|
||||
const updaterIpc = createUpdaterIpc(updater)
|
||||
const wslIpc = createDeferredWslIpc()
|
||||
const menu = {
|
||||
trigger: (id: string) => {
|
||||
const win = getLastFocusedWindow()
|
||||
@@ -44,7 +41,7 @@ const main = Effect.gen(function* () {
|
||||
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||
relaunch: lifecycle.relaunch,
|
||||
}
|
||||
const ipcDeps: Parameters<typeof registerIpcHandlers>[0] = {
|
||||
registerIpcHandlers({
|
||||
relaunch: lifecycle.relaunch,
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* () {
|
||||
@@ -69,8 +66,8 @@ const main = Effect.gen(function* () {
|
||||
setNativeTranslations: (bundle) => {
|
||||
if (setNativeTranslations(bundle)) createMenu(menu)
|
||||
},
|
||||
}
|
||||
yield* Effect.promise(() => registerIpcHandlers(ipcDeps, updaterIpc, wslIpc.ipc))
|
||||
})
|
||||
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
|
||||
startAutoUpdater(updater)
|
||||
yield* Effect.promise(() => startNetworkLogging())
|
||||
|
||||
@@ -79,7 +76,7 @@ const main = Effect.gen(function* () {
|
||||
logger.log("starting v2 background service")
|
||||
const background = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
const wsl = yield* Effect.promise(() => startWsl(background, logger))
|
||||
wslIpc.set(wsl.ipc)
|
||||
registerWslIpcHandlers(wsl.ipc)
|
||||
wsl.start()
|
||||
lifecycle.setWslShutdown(wsl.stop)
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { WebContents } from "electron"
|
||||
import { Effect, Queue, Stream } from "effect"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
|
||||
const queues = new Map<number, Queue.Queue<DesktopEvent>>()
|
||||
|
||||
export function bindIpcEvents(senderId: number) {
|
||||
const queue = Effect.runSync(Queue.unbounded<DesktopEvent>())
|
||||
queues.set(senderId, queue)
|
||||
return () => {
|
||||
if (queues.get(senderId) === queue) queues.delete(senderId)
|
||||
}
|
||||
}
|
||||
|
||||
export function ipcEventStream(senderId: number) {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const queue = queues.get(senderId) ?? (yield* Queue.unbounded<DesktopEvent>())
|
||||
if (!queues.has(senderId)) queues.set(senderId, queue)
|
||||
return Stream.fromQueue(queue)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function emitIpcEvent(sender: WebContents, event: DesktopEvent) {
|
||||
const queue = queues.get(sender.id)
|
||||
if (queue) Queue.offerUnsafe(queue, event)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import { Effect } from "effect"
|
||||
import type { FatalRendererError, ServerReadyData } from "../../shared/ipc-contract"
|
||||
import { AppRpcs } from "../../shared/ipc-rpc"
|
||||
import { setForceFocus } from "../native/debug"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { sender } from "./context"
|
||||
|
||||
export type AppHandlerDeps = {
|
||||
relaunch: () => void
|
||||
awaitInitialization: () => Promise<ServerReadyData>
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
|
||||
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
|
||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
setBackgroundColor: (color: string) => void
|
||||
exportDebugLogs: () => Promise<string>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
||||
setNativeTranslations: (bundle: DesktopNativeBundle) => void
|
||||
}
|
||||
|
||||
export function appHandlers(deps: AppHandlerDeps) {
|
||||
return AppRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return AppRpcs.of({
|
||||
AppAwaitInitialization: () => Effect.promise(() => deps.awaitInitialization()),
|
||||
AppConsumeInitialDeepLinks: () => promise(deps.consumeInitialDeepLinks),
|
||||
AppGetDefaultServerUrl: () => promise(deps.getDefaultServerUrl),
|
||||
AppSetDefaultServerUrl: ({ url }) => promise(() => deps.setDefaultServerUrl(url)),
|
||||
AppIsFirstLaunchOnboardingPending: () => promise(deps.isFirstLaunchOnboardingPending),
|
||||
AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) =>
|
||||
promise(() => deps.finishFirstLaunchOnboarding(createDefaultProject)),
|
||||
AppCheckAppExists: ({ appName }) => promise(() => deps.checkAppExists(appName)),
|
||||
AppResolveAppPath: ({ appName }) => Effect.promise(() => deps.resolveAppPath(appName)),
|
||||
AppSetBackgroundColor: ({ color }) => Effect.sync(() => deps.setBackgroundColor(color)),
|
||||
AppExportDebugLogs: () => Effect.promise(() => deps.exportDebugLogs()),
|
||||
AppSetForceFocus: ({ enabled }, context) =>
|
||||
Effect.promise(() => setForceFocus(sender(handoff, context), enabled)),
|
||||
AppRecordFatalRendererError: ({ error }) => promise(() => deps.recordFatalRendererError(error)),
|
||||
AppSetNativeTranslations: ({ value }, context) =>
|
||||
Effect.sync(() => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents) {
|
||||
throw new Error("Invalid native translation sender")
|
||||
}
|
||||
const bundle = parseDesktopNativeBundle(value)
|
||||
if (!bundle) throw new Error("Invalid native translation bundle")
|
||||
deps.setNativeTranslations(bundle)
|
||||
}),
|
||||
AppRelaunch: () => Effect.sync(deps.relaunch),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function promise<A>(evaluate: () => A | Promise<A>) {
|
||||
return Effect.promise(async () => evaluate())
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { IpcPortHandoff } from "../ipc-transport"
|
||||
|
||||
export type RpcContext = { readonly client: { readonly id: number } }
|
||||
|
||||
export function sender(handoff: IpcPortHandoff["Service"], context: RpcContext) {
|
||||
const contents = handoff.sender(context.client.id)
|
||||
if (!contents || contents.isDestroyed()) throw new Error("Renderer connection not found")
|
||||
return contents
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const eventHandlers = EventRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return EventRpcs.of({
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { FileRpcs } from "../../shared/ipc-rpc"
|
||||
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "../files"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { sender } from "./context"
|
||||
|
||||
export function fileHandlers(files: ReturnType<typeof createFileCapabilities>) {
|
||||
return FileRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return FileRpcs.of({
|
||||
FilesOpenDirectoryPicker: ({ options }) => Effect.promise(() => files.openDirectoryPicker(options)),
|
||||
FilesOpenFilePicker: ({ options }, context) =>
|
||||
Effect.promise(() =>
|
||||
files.openFilePicker(
|
||||
sender(handoff, context).id,
|
||||
options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined,
|
||||
),
|
||||
),
|
||||
FilesReadPickedFile: ({ token, path }, context) =>
|
||||
Effect.promise(
|
||||
async () => new Uint8Array(await files.readPickedFile(sender(handoff, context).id, token, path)),
|
||||
),
|
||||
FilesReleasePickedFiles: ({ token }, context) =>
|
||||
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
|
||||
FilesSaveFilePicker: ({ options }) => Effect.promise(() => files.saveFilePicker(options)),
|
||||
FilesOpenExternal: ({ url }) => Effect.sync(() => openExternalURL(url)),
|
||||
FilesOpenLocalFile: ({ url }) => Effect.sync(() => openLocalFileURL(url)),
|
||||
FilesOpenPath: ({ path, application }) =>
|
||||
Effect.promise(async () => (await files.openPath(path, application)) ?? null),
|
||||
FilesRevealPath: ({ path }) => Effect.promise(() => files.revealPath(path)),
|
||||
FilesReadClipboardImage: () =>
|
||||
Effect.sync(() => {
|
||||
const image = files.readClipboardImage()
|
||||
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { MenuRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { runDesktopMenuAction } from "../native/menu-actions"
|
||||
import { sender } from "./context"
|
||||
|
||||
export function menuHandlers(deps: {
|
||||
readonly showUpdater: () => Promise<void> | void
|
||||
readonly relaunch: () => void
|
||||
}) {
|
||||
return MenuRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return MenuRpcs.of({
|
||||
MenuRunAction: ({ action }, context) =>
|
||||
Effect.sync(() =>
|
||||
runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, {
|
||||
checkForUpdates: () => void deps.showUpdater(),
|
||||
relaunch: deps.relaunch,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { StorageRpcs } from "../../shared/ipc-rpc"
|
||||
import type { createDesktopStorage } from "../storage"
|
||||
|
||||
export function storageHandlers(storage: ReturnType<typeof createDesktopStorage>) {
|
||||
return StorageRpcs.toLayer(
|
||||
Effect.succeed(
|
||||
StorageRpcs.of({
|
||||
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
|
||||
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
|
||||
StorageDelete: ({ name, key }) => Effect.sync(() => storage.deleteValue(name, key)),
|
||||
StorageClear: ({ name }) => Effect.sync(() => storage.clear(name)),
|
||||
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
|
||||
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
|
||||
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
|
||||
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
|
||||
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
|
||||
DraftsPutBlob: ({ data }) =>
|
||||
Effect.sync(() =>
|
||||
storage.drafts.putBlob(
|
||||
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,
|
||||
),
|
||||
),
|
||||
DraftsGetBlob: ({ id }) =>
|
||||
Effect.sync(() => {
|
||||
const data = storage.drafts.getBlob(id)
|
||||
return data ? new Uint8Array(data) : null
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { UpdaterRpcs } from "../../shared/ipc-rpc"
|
||||
import type { UpdaterIpc } from "../updater"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { sender } from "./context"
|
||||
|
||||
export function updaterHandlers(updater: UpdaterIpc) {
|
||||
return UpdaterRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return UpdaterRpcs.of({
|
||||
UpdaterSubscribe: (_args, context) => Effect.sync(() => updater.subscribe(sender(handoff, context))),
|
||||
UpdaterUnsubscribe: (_args, context) => Effect.sync(() => updater.unsubscribe(sender(handoff, context).id)),
|
||||
UpdaterCheck: () => Effect.promise(() => updater.check()),
|
||||
UpdaterInstall: () => Effect.promise(() => updater.install()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { WindowRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "../windows"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const windowHandlers = WindowRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return WindowRpcs.of({
|
||||
WindowGetId: (_args, context) =>
|
||||
Effect.sync(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (!win) throw new Error("Window not found")
|
||||
const id = getWindowID(win)
|
||||
if (!id) throw new Error("Window ID not found")
|
||||
return id
|
||||
}),
|
||||
WindowGetFocused: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFocused() ?? false),
|
||||
WindowGetFullscreen: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFullScreen() ?? false),
|
||||
WindowSetFocus: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.focus()),
|
||||
WindowShow: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.show()),
|
||||
WindowGetZoomFactor: (_args, context) => Effect.sync(() => sender(handoff, context).getZoomFactor()),
|
||||
WindowSetZoomFactor: ({ factor }, context) =>
|
||||
Effect.sync(() => {
|
||||
const contents = sender(handoff, context)
|
||||
contents.setZoomFactor(factor)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (win) updateTitlebar(win)
|
||||
}),
|
||||
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
|
||||
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
|
||||
WindowSetTitlebar: ({ theme }, context) =>
|
||||
Effect.sync(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (win) setTitlebar(win, theme)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { WslRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import type { WslIpc } from "../wsl/ipc"
|
||||
import { sender } from "./context"
|
||||
|
||||
export function wslHandlers(wsl: WslIpc) {
|
||||
return WslRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return WslRpcs.of({
|
||||
WslSubscribe: (_args, context) => Effect.sync(() => wsl.subscribe(sender(handoff, context))),
|
||||
WslUnsubscribe: (_args, context) => Effect.sync(() => wsl.unsubscribe(sender(handoff, context).id)),
|
||||
WslGetState: () => Effect.sync(() => wsl.getState()),
|
||||
WslProbeRuntime: () => Effect.promise(() => wsl.probeRuntime()),
|
||||
WslRefreshDistros: () => Effect.promise(() => wsl.refreshDistros()),
|
||||
WslInstallWsl: () => Effect.promise(() => wsl.installWsl()),
|
||||
WslInstallDistro: ({ name }) => Effect.promise(() => wsl.installDistro(name)),
|
||||
WslProbeAddable: ({ distros }) => Effect.promise(() => wsl.probeAddable([...distros])),
|
||||
WslInstallOpencode: ({ name }) => Effect.promise(() => wsl.installOpencode(name)),
|
||||
WslOpenTerminal: ({ name }) => Effect.promise(() => wsl.openTerminal(name)),
|
||||
WslAddServer: ({ distro }) => Effect.promise(() => wsl.addServer(distro)),
|
||||
WslRemoveServer: ({ id }) => Effect.promise(() => wsl.removeServer(id)),
|
||||
WslStartServer: ({ id }) => Effect.promise(() => wsl.startServer(id)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { MessageChannel } from "node:worker_threads"
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
|
||||
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
|
||||
|
||||
describe("desktop RPC transport", () => {
|
||||
test("keeps multiple renderer ports independent", async () => {
|
||||
const handlers = TestRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return TestRpcs.of({
|
||||
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
|
||||
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
|
||||
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
|
||||
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
|
||||
const runtime = ManagedRuntime.make(live)
|
||||
const handoff = await runtime.runPromise(IpcPortHandoff)
|
||||
const first = new MessageChannel()
|
||||
const second = new MessageChannel()
|
||||
handoff.bind(sender(1), serverPort(first.port1))
|
||||
handoff.bind(sender(2), serverPort(second.port1))
|
||||
const firstClient = makeClient(first.port2)
|
||||
const secondClient = makeClient(second.port2)
|
||||
|
||||
const [focused, unfocused] = await Promise.all([callFocused(firstClient), callFocused(secondClient)])
|
||||
|
||||
expect(focused).toBe(true)
|
||||
expect(unfocused).toBe(false)
|
||||
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
|
||||
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
|
||||
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
|
||||
|
||||
const reloaded = new MessageChannel()
|
||||
handoff.bind(sender(1), serverPort(reloaded.port1))
|
||||
const reloadedClient = makeClient(reloaded.port2)
|
||||
const [reloadedFocused, stillUnfocused] = await Promise.all([
|
||||
callFocused(reloadedClient),
|
||||
callFocused(secondClient),
|
||||
])
|
||||
expect(reloadedFocused).toBe(true)
|
||||
expect(stillUnfocused).toBe(false)
|
||||
await Promise.all([firstClient.dispose(), secondClient.dispose(), reloadedClient.dispose()])
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
|
||||
const TestRpcs = RpcGroup.make(
|
||||
Rpc.make("test.focused", { success: Schema.Boolean }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
|
||||
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
|
||||
Rpc.make("test.events", { success: TestEvent, stream: true }),
|
||||
)
|
||||
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
|
||||
|
||||
class TestClient extends Context.Service<TestClient, TestRpcClient>()("opencode/desktop/TestClient") {}
|
||||
|
||||
function makeClient(port: MessagePort) {
|
||||
return ManagedRuntime.make(
|
||||
Layer.effect(TestClient, RpcClient.make(TestRpcs)).pipe(Layer.provide(clientProtocol(port))),
|
||||
)
|
||||
}
|
||||
|
||||
function callFocused(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
|
||||
return runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestClient
|
||||
return yield* client["test.focused"]()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function putBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>, data: Uint8Array) {
|
||||
return runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestClient
|
||||
return yield* client["test.blob.put"]({ data })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
|
||||
return runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestClient
|
||||
return yield* client["test.blob.get"]()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function firstEvent(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
|
||||
return runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestClient
|
||||
return yield* client["test.events"]().pipe(Stream.runHead, Effect.map(Option.getOrThrow))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function clientProtocol(port: MessagePort) {
|
||||
return Layer.effect(
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) =>
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
port.addEventListener("message", onMessage)
|
||||
port.start()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
port.removeEventListener("message", onMessage)
|
||||
port.close()
|
||||
}),
|
||||
)
|
||||
yield* Stream.fromQueue(inbound).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) port.postMessage(encoded)
|
||||
}),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
}
|
||||
|
||||
function sender(id: number) {
|
||||
const events = new EventEmitter()
|
||||
return {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
once: events.once.bind(events),
|
||||
off: events.off.bind(events),
|
||||
} as unknown as WebContents
|
||||
}
|
||||
|
||||
function serverPort(port: import("node:worker_threads").MessagePort) {
|
||||
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>()
|
||||
return {
|
||||
on(event: string, listener: (event: Electron.MessageEvent) => void) {
|
||||
if (event !== "message") {
|
||||
port.on(event, listener)
|
||||
return
|
||||
}
|
||||
const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent)
|
||||
listeners.set(listener, wrapped)
|
||||
port.on("message", wrapped)
|
||||
},
|
||||
off(event: string, listener: (event: Electron.MessageEvent) => void) {
|
||||
if (event !== "message") {
|
||||
port.off(event, listener)
|
||||
return
|
||||
}
|
||||
const wrapped = listeners.get(listener)
|
||||
if (wrapped) port.off("message", wrapped)
|
||||
},
|
||||
postMessage: port.postMessage.bind(port),
|
||||
start: port.start.bind(port),
|
||||
close: port.close.bind(port),
|
||||
} as unknown as MessagePortMain
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
|
||||
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { bindIpcEvents } from "./ipc-events"
|
||||
|
||||
type PortBinding = {
|
||||
readonly id: number
|
||||
readonly sender: WebContents
|
||||
readonly port: MessagePortMain
|
||||
readonly parser: RpcSerialization.Parser
|
||||
readonly onMessage: (event: Electron.MessageEvent) => void
|
||||
readonly onClose: () => void
|
||||
readonly unbindEvents: () => void
|
||||
}
|
||||
|
||||
type Handoff = {
|
||||
readonly bind: (sender: WebContents, port: MessagePortMain) => void
|
||||
readonly sender: (clientId: number) => WebContents | undefined
|
||||
}
|
||||
|
||||
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
|
||||
|
||||
export const IpcServerProtocolLive = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
|
||||
const bindings = new Map<number, PortBinding>()
|
||||
const senderBindings = new Map<number, number>()
|
||||
|
||||
const protocol = Layer.effect(
|
||||
RpcServer.Protocol,
|
||||
RpcServer.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeRequest) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const disconnects = yield* Queue.unbounded<number>()
|
||||
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
|
||||
let nextClientId = 0
|
||||
|
||||
const disconnect = (id: number) => {
|
||||
const binding = bindings.get(id)
|
||||
if (!binding) return
|
||||
bindings.delete(id)
|
||||
if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id)
|
||||
binding.port.off("message", binding.onMessage)
|
||||
binding.port.off("close", binding.onClose)
|
||||
binding.sender.off("destroyed", binding.onClose)
|
||||
binding.unbindEvents()
|
||||
binding.port.close()
|
||||
Queue.offerUnsafe(disconnects, id)
|
||||
}
|
||||
|
||||
const bind = (sender: WebContents, port: MessagePortMain) => {
|
||||
const previous = senderBindings.get(sender.id)
|
||||
if (previous !== undefined) disconnect(previous)
|
||||
if (sender.isDestroyed()) {
|
||||
port.close()
|
||||
return
|
||||
}
|
||||
|
||||
const id = nextClientId++
|
||||
const parser = serialization.makeUnsafe()
|
||||
const onMessage = (event: Electron.MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) =>
|
||||
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const onClose = () => disconnect(id)
|
||||
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents: bindIpcEvents(sender.id) }
|
||||
bindings.set(id, binding)
|
||||
senderBindings.set(sender.id, id)
|
||||
port.on("message", onMessage)
|
||||
port.on("close", onClose)
|
||||
sender.once("destroyed", onClose)
|
||||
port.start()
|
||||
}
|
||||
|
||||
yield* Stream.fromQueue(handoffs).pipe(
|
||||
Stream.runForEach(([sender, port]) => Effect.sync(() => bind(sender, port))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Stream.fromQueue(inbound).pipe(
|
||||
Stream.runForEach(([id, message]) => (bindings.has(id) ? writeRequest(id, message) : Effect.void)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => [...bindings.keys()].forEach(disconnect)))
|
||||
|
||||
return {
|
||||
disconnects,
|
||||
send: (clientId, response) =>
|
||||
Effect.sync(() => {
|
||||
const binding = bindings.get(clientId)
|
||||
if (!binding) return
|
||||
const encoded = binding.parser.encode(response)
|
||||
if (encoded !== undefined) binding.port.postMessage(encoded)
|
||||
}),
|
||||
end: (clientId) => Effect.sync(() => disconnect(clientId)),
|
||||
clientIds: Effect.sync(() => new Set(bindings.keys())),
|
||||
initialMessage: Effect.succeed(Option.none()),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
supportsSpanPropagation: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Layer.merge(
|
||||
protocol,
|
||||
Layer.succeed(IpcPortHandoff)({
|
||||
bind: (sender, port) => {
|
||||
Queue.offerUnsafe(handoffs, [sender, port])
|
||||
},
|
||||
sender: (clientId) => bindings.get(clientId)?.sender,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
@@ -1,55 +1,184 @@
|
||||
import { app, BrowserWindow, MessageChannelMain } from "electron"
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { RpcServer } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs } from "../shared/ipc-rpc"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { createFileCapabilities } from "./files"
|
||||
import { appHandlers, type AppHandlerDeps } from "./ipc-handlers/app"
|
||||
import { eventHandlers } from "./ipc-handlers/events"
|
||||
import { fileHandlers } from "./ipc-handlers/files"
|
||||
import { menuHandlers } from "./ipc-handlers/menu"
|
||||
import { storageHandlers } from "./ipc-handlers/storage"
|
||||
import { updaterHandlers } from "./ipc-handlers/updater"
|
||||
import { windowHandlers } from "./ipc-handlers/window"
|
||||
import { wslHandlers } from "./ipc-handlers/wsl"
|
||||
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
|
||||
import { BrowserWindow, ipcMain } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
|
||||
import {
|
||||
Ipc,
|
||||
type FatalRendererError,
|
||||
type IpcInvoke,
|
||||
type IpcInvokeArgs,
|
||||
type IpcInvokeResult,
|
||||
type IpcSend,
|
||||
type ServerReadyData,
|
||||
} from "../shared/ipc-contract"
|
||||
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./files"
|
||||
import { setForceFocus } from "./native/debug"
|
||||
import { runDesktopMenuAction } from "./native/menu-actions"
|
||||
import { createDesktopStorage } from "./storage"
|
||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import type { UpdaterIpc } from "./updater"
|
||||
import type { WslIpc } from "./wsl/ipc"
|
||||
|
||||
type Deps = AppHandlerDeps & {
|
||||
showUpdater: () => Promise<void> | void
|
||||
type MaybePromise<Value> = Value | Promise<Value>
|
||||
|
||||
function handle<Channel extends keyof IpcInvoke>(
|
||||
channel: Channel,
|
||||
listener: (event: IpcMainInvokeEvent, ...args: IpcInvokeArgs<Channel>) => MaybePromise<IpcInvokeResult<Channel>>,
|
||||
) {
|
||||
ipcMain.handle(channel, listener)
|
||||
}
|
||||
|
||||
export async function registerIpcHandlers(deps: Deps, updater: UpdaterIpc, wsl: WslIpc) {
|
||||
const handlers = Layer.mergeAll(
|
||||
appHandlers(deps),
|
||||
storageHandlers(createDesktopStorage()),
|
||||
fileHandlers(createFileCapabilities()),
|
||||
windowHandlers,
|
||||
menuHandlers(deps),
|
||||
updaterHandlers(updater),
|
||||
wslHandlers(wsl),
|
||||
eventHandlers,
|
||||
function on<Channel extends keyof IpcSend>(
|
||||
channel: Channel,
|
||||
listener: (event: IpcMainEvent, ...args: IpcSend[Channel]) => void,
|
||||
) {
|
||||
ipcMain.on(channel, listener)
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
relaunch: () => void
|
||||
awaitInitialization: () => Promise<ServerReadyData>
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
|
||||
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
|
||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
showUpdater: () => Promise<void> | void
|
||||
setBackgroundColor: (color: string) => void
|
||||
exportDebugLogs: () => Promise<string>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
||||
setNativeTranslations: (bundle: DesktopNativeBundle) => void
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const files = createFileCapabilities()
|
||||
const storage = createDesktopStorage()
|
||||
|
||||
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
|
||||
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
|
||||
handle(Ipc.app.getDefaultServerUrl, () => deps.getDefaultServerUrl())
|
||||
handle(Ipc.app.setDefaultServerUrl, (_event, url) => deps.setDefaultServerUrl(url))
|
||||
handle(Ipc.app.isFirstLaunchOnboardingPending, () => deps.isFirstLaunchOnboardingPending())
|
||||
handle(Ipc.app.finishFirstLaunchOnboarding, (_event, createDefaultProject) =>
|
||||
deps.finishFirstLaunchOnboarding(createDefaultProject),
|
||||
)
|
||||
const live = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
|
||||
Layer.provide(handlers),
|
||||
Layer.provideMerge(IpcServerProtocolLive),
|
||||
)
|
||||
const runtime = ManagedRuntime.make(live)
|
||||
const handoff = await runtime.runPromise(IpcPortHandoff)
|
||||
const wire = (_event: Electron.Event, win: BrowserWindow) => {
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(win.webContents, channel.port1)
|
||||
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
handle(Ipc.app.checkAppExists, (_event, appName) => deps.checkAppExists(appName))
|
||||
handle(Ipc.app.resolveAppPath, (_event, appName) => deps.resolveAppPath(appName))
|
||||
handle(Ipc.app.setBackgroundColor, (_event, color) => deps.setBackgroundColor(color))
|
||||
handle(Ipc.app.exportDebugLogs, () => deps.exportDebugLogs())
|
||||
handle(Ipc.app.setForceFocus, (event, enabled) => setForceFocus(event.sender, enabled))
|
||||
handle(Ipc.app.recordFatalRendererError, (_event, error) => deps.recordFatalRendererError(error))
|
||||
handle(Ipc.app.setNativeTranslations, (event, value) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) {
|
||||
throw new Error("Invalid native translation sender")
|
||||
}
|
||||
const bundle = parseDesktopNativeBundle(value)
|
||||
if (!bundle) throw new Error("Invalid native translation bundle")
|
||||
deps.setNativeTranslations(bundle)
|
||||
})
|
||||
handle(Ipc.storage.get, (_event, name, key) => {
|
||||
return storage.get(name, key)
|
||||
})
|
||||
handle(Ipc.storage.set, (_event, name, key, value) => storage.set(name, key, value))
|
||||
handle(Ipc.storage.delete, (_event, name, key) => storage.deleteValue(name, key))
|
||||
handle(Ipc.storage.clear, (_event, name) => storage.clear(name))
|
||||
handle(Ipc.storage.keys, (_event, name) => storage.keys(name))
|
||||
handle(Ipc.storage.length, (_event, name) => storage.length(name))
|
||||
handle(Ipc.drafts.get, (_event, key) => storage.drafts.get(key))
|
||||
handle(Ipc.drafts.set, (_event, key, value) => storage.drafts.set(key, value))
|
||||
handle(Ipc.drafts.delete, (_event, key) => storage.drafts.set(key, null))
|
||||
handle(Ipc.drafts.putBlob, (_event, data) => storage.drafts.putBlob(data))
|
||||
handle(Ipc.drafts.getBlob, (_event, id) => storage.drafts.getBlob(id))
|
||||
|
||||
handle(Ipc.files.openDirectoryPicker, (_event, options) => files.openDirectoryPicker(options))
|
||||
handle(Ipc.files.openFilePicker, (event, options) => files.openFilePicker(event.sender.id, options))
|
||||
handle(Ipc.files.readPickedFile, (event, token, path) => files.readPickedFile(event.sender.id, token, path))
|
||||
handle(Ipc.files.releasePickedFiles, (event, token) => files.releasePickedFiles(event.sender.id, token))
|
||||
handle(Ipc.files.saveFilePicker, (_event, options) => files.saveFilePicker(options))
|
||||
on(Ipc.files.openExternal, (_event, url) => openExternalURL(url))
|
||||
on(Ipc.files.openLocalFile, (_event, url) => openLocalFileURL(url))
|
||||
handle(Ipc.files.openPath, (_event, path, app) => files.openPath(path, app))
|
||||
handle(Ipc.files.revealPath, (_event, path) => files.revealPath(path))
|
||||
handle(Ipc.files.readClipboardImage, () => files.readClipboardImage())
|
||||
|
||||
handle(Ipc.window.getId, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) throw new Error("Window not found")
|
||||
const id = getWindowID(win)
|
||||
if (!id) throw new Error("Window ID not found")
|
||||
return id
|
||||
})
|
||||
|
||||
handle(Ipc.window.getFocused, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFocused() ?? false
|
||||
})
|
||||
|
||||
handle(Ipc.window.getFullscreen, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFullScreen() ?? false
|
||||
})
|
||||
|
||||
handle(Ipc.window.setFocus, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.focus()
|
||||
})
|
||||
|
||||
handle(Ipc.window.show, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.show()
|
||||
})
|
||||
|
||||
on(Ipc.app.relaunch, () => {
|
||||
deps.relaunch()
|
||||
})
|
||||
|
||||
handle(Ipc.window.getZoomFactor, (event) => event.sender.getZoomFactor())
|
||||
handle(Ipc.window.setZoomFactor, (event, factor) => {
|
||||
event.sender.setZoomFactor(factor)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
updateTitlebar(win)
|
||||
})
|
||||
handle(Ipc.window.getPinchZoomEnabled, () => getPinchZoomEnabled())
|
||||
handle(Ipc.window.setPinchZoomEnabled, (_event, enabled) => {
|
||||
setPinchZoomEnabled(enabled)
|
||||
})
|
||||
handle(Ipc.window.setTitlebar, (event, theme) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
setTitlebar(win, theme)
|
||||
})
|
||||
handle(Ipc.menu.runAction, (event, action) => {
|
||||
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
|
||||
checkForUpdates: () => void deps.showUpdater(),
|
||||
relaunch: deps.relaunch,
|
||||
})
|
||||
}
|
||||
app.on("browser-window-created", wire)
|
||||
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
|
||||
app.once("will-quit", () => {
|
||||
app.off("browser-window-created", wire)
|
||||
void runtime.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
export function registerUpdaterIpcHandlers(updater: UpdaterIpc) {
|
||||
handle(Ipc.updater.subscribe, (event) => updater.subscribe(event.sender))
|
||||
handle(Ipc.updater.unsubscribe, (event) => updater.unsubscribe(event.sender.id))
|
||||
handle(Ipc.updater.check, () => updater.check())
|
||||
handle(Ipc.updater.install, () => updater.install())
|
||||
}
|
||||
|
||||
export function registerWslIpcHandlers(wsl: WslIpc) {
|
||||
handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender))
|
||||
handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id))
|
||||
handle(Ipc.wsl.getState, () => wsl.getState())
|
||||
handle(Ipc.wsl.probeRuntime, () => wsl.probeRuntime())
|
||||
handle(Ipc.wsl.refreshDistros, () => wsl.refreshDistros())
|
||||
handle(Ipc.wsl.installWsl, () => wsl.installWsl())
|
||||
handle(Ipc.wsl.installDistro, (_event, value) => wsl.installDistro(value))
|
||||
handle(Ipc.wsl.probeAddable, (_event, value) => wsl.probeAddable(value))
|
||||
handle(Ipc.wsl.installOpencode, (_event, value) => wsl.installOpencode(value))
|
||||
handle(Ipc.wsl.openTerminal, (_event, value) => wsl.openTerminal(value))
|
||||
handle(Ipc.wsl.addServer, (_event, value) => wsl.addServer(value))
|
||||
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
|
||||
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import type { Event } from "electron"
|
||||
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { writeLog, type DesktopLogger } from "../native/logging"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
@@ -13,7 +12,7 @@ export function createApplicationLifecycle(logger: DesktopLogger) {
|
||||
if (!urls.length) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
|
||||
if (win) sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
|
||||
}
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
type DesktopMenuEntry,
|
||||
type DesktopMenuRole,
|
||||
} from "@opencode-ai/app/desktop-menu"
|
||||
import { MenuCommandTriggered } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
|
||||
import { UPDATER_ENABLED } from "../constants"
|
||||
import { openExternalURL } from "../files"
|
||||
@@ -37,7 +36,7 @@ export function createMenu(deps: Deps) {
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
emitIpcEvent(win.webContents, new MenuCommandTriggered({ id }))
|
||||
sendIpcEvent(win.webContents, Ipc.menu.command, id)
|
||||
}
|
||||
|
||||
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { app, dialog } from "electron"
|
||||
import type { WebContents } from "electron"
|
||||
import { UpdaterStateChanged } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { UPDATER_ENABLED } from "../constants"
|
||||
import { getLogger } from "../native/logging"
|
||||
import { nativeT } from "../native/translations"
|
||||
@@ -54,7 +53,7 @@ export function createUpdaterIpc(controller: UpdaterController) {
|
||||
id,
|
||||
controller.subscribe((state) => {
|
||||
if (sender.isDestroyed()) return unsubscribe(id)
|
||||
emitIpcEvent(sender, new UpdaterStateChanged({ state }))
|
||||
sendIpcEvent(sender, Ipc.updater.state, state)
|
||||
}),
|
||||
)
|
||||
sender.once("destroyed", () => unsubscribe(id))
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
|
||||
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
|
||||
import { join } from "node:path"
|
||||
import { type TitlebarTheme } from "../../shared/ipc-contract"
|
||||
import { WindowFullscreenChanged, WindowPinchZoomChanged, WindowZoomChanged } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../../shared/ipc-contract"
|
||||
import { developmentResourcesRoot, preloadPath } from "../paths"
|
||||
import { PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
@@ -84,7 +82,7 @@ export function setPinchZoomEnabled(enabled: boolean) {
|
||||
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
pinchZoomEnabled.set(win, enabled)
|
||||
emitIpcEvent(win.webContents, new WindowPinchZoomChanged({ enabled }))
|
||||
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
|
||||
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
||||
updateZoom(win)
|
||||
})
|
||||
@@ -113,7 +111,7 @@ export function wireZoom(win: BrowserWindow) {
|
||||
export function wireFullscreen(win: BrowserWindow) {
|
||||
const send = (fullscreen: boolean) => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
emitIpcEvent(win.webContents, new WindowFullscreenChanged({ fullscreen }))
|
||||
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
|
||||
}
|
||||
win.on("enter-full-screen", () => send(true))
|
||||
win.on("leave-full-screen", () => send(false))
|
||||
@@ -146,5 +144,5 @@ function clampZoom(value: number) {
|
||||
|
||||
function updateZoom(win: BrowserWindow) {
|
||||
updateTitlebar(win)
|
||||
emitIpcEvent(win.webContents, new WindowZoomChanged({ factor: win.webContents.getZoomFactor() }))
|
||||
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { app } from "electron"
|
||||
import type { WebContents } from "electron"
|
||||
import type { WslServerConfig, WslServersState } from "@opencode-ai/app/wsl/types"
|
||||
import { WslServersChanged } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import type { WslServersController } from "./servers"
|
||||
import { nativeT } from "../native/translations"
|
||||
|
||||
@@ -22,34 +21,6 @@ export type WslIpc = {
|
||||
startServer(value: string): Promise<void>
|
||||
}
|
||||
|
||||
export function createDeferredWslIpc() {
|
||||
let current: WslIpc | undefined
|
||||
const get = () => {
|
||||
if (!current) throw new Error("WSL service is not initialized")
|
||||
return current
|
||||
}
|
||||
return {
|
||||
ipc: {
|
||||
subscribe: (sender) => get().subscribe(sender),
|
||||
unsubscribe: (id) => get().unsubscribe(id),
|
||||
getState: () => get().getState(),
|
||||
probeRuntime: () => get().probeRuntime(),
|
||||
refreshDistros: () => get().refreshDistros(),
|
||||
installWsl: () => get().installWsl(),
|
||||
installDistro: (value) => get().installDistro(value),
|
||||
probeAddable: (value) => get().probeAddable(value),
|
||||
installOpencode: (value) => get().installOpencode(value),
|
||||
openTerminal: (value) => get().openTerminal(value),
|
||||
addServer: (value) => get().addServer(value),
|
||||
removeServer: (value) => get().removeServer(value),
|
||||
startServer: (value) => get().startServer(value),
|
||||
} satisfies WslIpc,
|
||||
set: (ipc: WslIpc) => {
|
||||
current = ipc
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createWslIpc(controller?: WslServersController): WslIpc {
|
||||
if (!controller) return createUnavailableWslIpc()
|
||||
|
||||
@@ -77,7 +48,7 @@ export function createWslIpc(controller?: WslServersController): WslIpc {
|
||||
unsubscribe(id)
|
||||
return
|
||||
}
|
||||
emitIpcEvent(sender, new WslServersChanged({ event: payload }))
|
||||
sendIpcEvent(sender, Ipc.wsl.event, payload)
|
||||
}),
|
||||
)
|
||||
sender.once("destroyed", () => unsubscribe(id))
|
||||
@@ -117,7 +88,7 @@ function createUnavailableWslIpc(): WslIpc {
|
||||
})
|
||||
|
||||
return {
|
||||
subscribe: (sender) => emitIpcEvent(sender, new WslServersChanged({ event: { type: "state", state: state() } })),
|
||||
subscribe: (sender) => sendIpcEvent(sender, Ipc.wsl.event, { type: "state", state: state() }),
|
||||
unsubscribe: () => undefined,
|
||||
getState: state,
|
||||
probeRuntime: unavailable,
|
||||
|
||||
@@ -1,11 +1,138 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
import type { IpcRendererEvent } from "electron"
|
||||
import type { ElectronAPI } from "./types"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import {
|
||||
Ipc,
|
||||
type IpcEvent,
|
||||
type IpcEventListener,
|
||||
type IpcInvoke,
|
||||
type IpcInvokeArgs,
|
||||
type IpcInvokeResult,
|
||||
type IpcSend,
|
||||
} from "../shared/ipc-contract"
|
||||
|
||||
ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
const port = event.ports[0]
|
||||
if (port) window.postMessage(IpcTransportPort, "*", [port])
|
||||
})
|
||||
function invoke<Channel extends keyof IpcInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
|
||||
return ipcRenderer.invoke(channel, ...args) as Promise<IpcInvokeResult<Channel>>
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
})
|
||||
function send<Channel extends keyof IpcSend>(channel: Channel, ...args: IpcSend[Channel]) {
|
||||
ipcRenderer.send(channel, ...args)
|
||||
}
|
||||
|
||||
function listen<Channel extends keyof IpcEvent>(channel: Channel, listener: IpcEventListener<Channel>) {
|
||||
const handler = (_event: IpcRendererEvent, ...args: IpcEvent[Channel]) => listener(...args)
|
||||
ipcRenderer.on(channel, handler)
|
||||
return () => ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
|
||||
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
|
||||
let updaterState: UpdaterState | undefined
|
||||
let updaterSubscription: Promise<void> | undefined
|
||||
let updaterListener: (() => void) | undefined
|
||||
const updaterHandler = (state: UpdaterState) => {
|
||||
updaterState = state
|
||||
updaterCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
const api: ElectronAPI = {
|
||||
awaitInitialization: () => invoke(Ipc.app.awaitInitialization),
|
||||
wslServers: {
|
||||
getState: () => invoke(Ipc.wsl.getState),
|
||||
subscribe: (cb) => {
|
||||
const dispose = listen(Ipc.wsl.event, cb)
|
||||
void invoke(Ipc.wsl.subscribe)
|
||||
return () => {
|
||||
dispose()
|
||||
void invoke(Ipc.wsl.unsubscribe)
|
||||
}
|
||||
},
|
||||
probeRuntime: () => invoke(Ipc.wsl.probeRuntime),
|
||||
refreshDistros: () => invoke(Ipc.wsl.refreshDistros),
|
||||
installWsl: () => invoke(Ipc.wsl.installWsl),
|
||||
installDistro: (name) => invoke(Ipc.wsl.installDistro, name),
|
||||
probeAddable: (distros) => invoke(Ipc.wsl.probeAddable, distros),
|
||||
installOpencode: (name) => invoke(Ipc.wsl.installOpencode, name),
|
||||
openTerminal: (name) => invoke(Ipc.wsl.openTerminal, name),
|
||||
addServer: (distro) => invoke(Ipc.wsl.addServer, distro),
|
||||
removeServer: (id) => invoke(Ipc.wsl.removeServer, id),
|
||||
startServer: (id) => invoke(Ipc.wsl.startServer, id),
|
||||
},
|
||||
updater: {
|
||||
subscribe: async (cb) => {
|
||||
updaterCallbacks.add(cb)
|
||||
if (updaterState) cb(updaterState)
|
||||
if (!updaterSubscription) {
|
||||
updaterListener = listen(Ipc.updater.state, updaterHandler)
|
||||
updaterSubscription = invoke(Ipc.updater.subscribe)
|
||||
}
|
||||
await updaterSubscription
|
||||
return () => {
|
||||
updaterCallbacks.delete(cb)
|
||||
if (updaterCallbacks.size > 0) return
|
||||
updaterListener?.()
|
||||
updaterListener = undefined
|
||||
updaterSubscription = undefined
|
||||
void invoke(Ipc.updater.unsubscribe)
|
||||
}
|
||||
},
|
||||
check: () => invoke(Ipc.updater.check),
|
||||
install: () => invoke(Ipc.updater.install),
|
||||
},
|
||||
consumeInitialDeepLinks: () => invoke(Ipc.app.consumeInitialDeepLinks),
|
||||
getDefaultServerUrl: () => invoke(Ipc.app.getDefaultServerUrl),
|
||||
setDefaultServerUrl: (url) => invoke(Ipc.app.setDefaultServerUrl, url),
|
||||
isFirstLaunchOnboardingPending: () => invoke(Ipc.app.isFirstLaunchOnboardingPending),
|
||||
finishFirstLaunchOnboarding: (createDefaultProject) =>
|
||||
invoke(Ipc.app.finishFirstLaunchOnboarding, createDefaultProject),
|
||||
checkAppExists: (appName) => invoke(Ipc.app.checkAppExists, appName),
|
||||
resolveAppPath: (appName) => invoke(Ipc.app.resolveAppPath, appName),
|
||||
storeGet: (name, key) => invoke(Ipc.storage.get, name, key),
|
||||
storeSet: (name, key, value) => invoke(Ipc.storage.set, name, key, value),
|
||||
storeDelete: (name, key) => invoke(Ipc.storage.delete, name, key),
|
||||
storeClear: (name) => invoke(Ipc.storage.clear, name),
|
||||
storeKeys: (name) => invoke(Ipc.storage.keys, name),
|
||||
storeLength: (name) => invoke(Ipc.storage.length, name),
|
||||
draftGet: (key) => invoke(Ipc.drafts.get, key),
|
||||
draftSet: (key, value) => invoke(Ipc.drafts.set, key, value),
|
||||
draftDelete: (key) => invoke(Ipc.drafts.delete, key),
|
||||
draftBlobPut: (data) => invoke(Ipc.drafts.putBlob, data),
|
||||
draftBlobGet: (id) => invoke(Ipc.drafts.getBlob, id),
|
||||
|
||||
getWindowID: () => invoke(Ipc.window.getId),
|
||||
onMenuCommand: (cb) => listen(Ipc.menu.command, cb),
|
||||
onDeepLink: (cb) => listen(Ipc.app.deepLink, cb),
|
||||
|
||||
openDirectoryPicker: (opts) => invoke(Ipc.files.openDirectoryPicker, opts),
|
||||
openFilePicker: (opts) => invoke(Ipc.files.openFilePicker, opts),
|
||||
readPickedFile: (token, path) => invoke(Ipc.files.readPickedFile, token, path),
|
||||
releasePickedFiles: (token) => invoke(Ipc.files.releasePickedFiles, token),
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||
saveFilePicker: (opts) => invoke(Ipc.files.saveFilePicker, opts),
|
||||
openExternal: (url) => send(Ipc.files.openExternal, url),
|
||||
openLocalFile: (url) => send(Ipc.files.openLocalFile, url),
|
||||
openPath: (path, app) => invoke(Ipc.files.openPath, path, app),
|
||||
revealPath: (path) => invoke(Ipc.files.revealPath, path),
|
||||
readClipboardImage: () => invoke(Ipc.files.readClipboardImage),
|
||||
getWindowFocused: () => invoke(Ipc.window.getFocused),
|
||||
getWindowFullscreen: () => invoke(Ipc.window.getFullscreen),
|
||||
onWindowFullscreenChanged: (cb) => listen(Ipc.window.fullscreenChanged, cb),
|
||||
setWindowFocus: () => invoke(Ipc.window.setFocus),
|
||||
showWindow: () => invoke(Ipc.window.show),
|
||||
relaunch: () => send(Ipc.app.relaunch),
|
||||
getZoomFactor: () => invoke(Ipc.window.getZoomFactor),
|
||||
setZoomFactor: (factor) => invoke(Ipc.window.setZoomFactor, factor),
|
||||
getPinchZoomEnabled: () => invoke(Ipc.window.getPinchZoomEnabled),
|
||||
setPinchZoomEnabled: (enabled) => invoke(Ipc.window.setPinchZoomEnabled, enabled),
|
||||
onPinchZoomEnabledChanged: (cb) => listen(Ipc.window.pinchZoomEnabledChanged, cb),
|
||||
onZoomFactorChanged: (cb) => listen(Ipc.window.zoomFactorChanged, cb),
|
||||
setTitlebar: (theme) => invoke(Ipc.window.setTitlebar, theme),
|
||||
runDesktopMenuAction: (action) => invoke(Ipc.menu.runAction, action),
|
||||
setBackgroundColor: (color) => invoke(Ipc.app.setBackgroundColor, color),
|
||||
exportDebugLogs: () => invoke(Ipc.app.exportDebugLogs),
|
||||
setForceFocus: (enabled) => invoke(Ipc.app.setForceFocus, enabled),
|
||||
recordFatalRendererError: (error) => invoke(Ipc.app.recordFatalRendererError, error),
|
||||
setNativeTranslations: (bundle) => invoke(Ipc.app.setNativeTranslations, bundle),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("api", api)
|
||||
|
||||
@@ -1,3 +1,74 @@
|
||||
export type ElectronNative = {
|
||||
getPathForFile(file: File): string
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import {
|
||||
Ipc,
|
||||
type IpcEventListener,
|
||||
type IpcEventSubscription,
|
||||
type IpcInvokeMethod,
|
||||
type IpcSendMethod,
|
||||
} from "../shared/ipc-contract"
|
||||
|
||||
export type WslServersAPI = WslServersPlatform
|
||||
export type UpdaterAPI = {
|
||||
subscribe: (cb: IpcEventListener<typeof Ipc.updater.state>) => Promise<() => void>
|
||||
check: IpcInvokeMethod<typeof Ipc.updater.check>
|
||||
install: IpcInvokeMethod<typeof Ipc.updater.install>
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
awaitInitialization: IpcInvokeMethod<typeof Ipc.app.awaitInitialization>
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks: IpcInvokeMethod<typeof Ipc.app.consumeInitialDeepLinks>
|
||||
getDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.getDefaultServerUrl>
|
||||
setDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.setDefaultServerUrl>
|
||||
isFirstLaunchOnboardingPending: IpcInvokeMethod<typeof Ipc.app.isFirstLaunchOnboardingPending>
|
||||
finishFirstLaunchOnboarding: IpcInvokeMethod<typeof Ipc.app.finishFirstLaunchOnboarding>
|
||||
checkAppExists: IpcInvokeMethod<typeof Ipc.app.checkAppExists>
|
||||
resolveAppPath: IpcInvokeMethod<typeof Ipc.app.resolveAppPath>
|
||||
storeGet: IpcInvokeMethod<typeof Ipc.storage.get>
|
||||
storeSet: IpcInvokeMethod<typeof Ipc.storage.set>
|
||||
storeDelete: IpcInvokeMethod<typeof Ipc.storage.delete>
|
||||
storeClear: IpcInvokeMethod<typeof Ipc.storage.clear>
|
||||
storeKeys: IpcInvokeMethod<typeof Ipc.storage.keys>
|
||||
storeLength: IpcInvokeMethod<typeof Ipc.storage.length>
|
||||
draftGet: IpcInvokeMethod<typeof Ipc.drafts.get>
|
||||
draftSet: IpcInvokeMethod<typeof Ipc.drafts.set>
|
||||
draftDelete: IpcInvokeMethod<typeof Ipc.drafts.delete>
|
||||
draftBlobPut: IpcInvokeMethod<typeof Ipc.drafts.putBlob>
|
||||
draftBlobGet: IpcInvokeMethod<typeof Ipc.drafts.getBlob>
|
||||
|
||||
getWindowID: IpcInvokeMethod<typeof Ipc.window.getId>
|
||||
onMenuCommand: IpcEventSubscription<typeof Ipc.menu.command>
|
||||
onDeepLink: IpcEventSubscription<typeof Ipc.app.deepLink>
|
||||
|
||||
openDirectoryPicker: IpcInvokeMethod<typeof Ipc.files.openDirectoryPicker>
|
||||
openFilePicker: IpcInvokeMethod<typeof Ipc.files.openFilePicker>
|
||||
readPickedFile: IpcInvokeMethod<typeof Ipc.files.readPickedFile>
|
||||
releasePickedFiles: IpcInvokeMethod<typeof Ipc.files.releasePickedFiles>
|
||||
getPathForFile: (file: File) => string
|
||||
saveFilePicker: IpcInvokeMethod<typeof Ipc.files.saveFilePicker>
|
||||
openExternal: IpcSendMethod<typeof Ipc.files.openExternal>
|
||||
openLocalFile: IpcSendMethod<typeof Ipc.files.openLocalFile>
|
||||
openPath: IpcInvokeMethod<typeof Ipc.files.openPath>
|
||||
revealPath: IpcInvokeMethod<typeof Ipc.files.revealPath>
|
||||
readClipboardImage: IpcInvokeMethod<typeof Ipc.files.readClipboardImage>
|
||||
getWindowFocused: IpcInvokeMethod<typeof Ipc.window.getFocused>
|
||||
getWindowFullscreen: IpcInvokeMethod<typeof Ipc.window.getFullscreen>
|
||||
onWindowFullscreenChanged: IpcEventSubscription<typeof Ipc.window.fullscreenChanged>
|
||||
setWindowFocus: IpcInvokeMethod<typeof Ipc.window.setFocus>
|
||||
showWindow: IpcInvokeMethod<typeof Ipc.window.show>
|
||||
relaunch: IpcSendMethod<typeof Ipc.app.relaunch>
|
||||
getZoomFactor: IpcInvokeMethod<typeof Ipc.window.getZoomFactor>
|
||||
setZoomFactor: IpcInvokeMethod<typeof Ipc.window.setZoomFactor>
|
||||
getPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.getPinchZoomEnabled>
|
||||
setPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.setPinchZoomEnabled>
|
||||
onPinchZoomEnabledChanged: IpcEventSubscription<typeof Ipc.window.pinchZoomEnabledChanged>
|
||||
onZoomFactorChanged: IpcEventSubscription<typeof Ipc.window.zoomFactorChanged>
|
||||
setTitlebar: IpcInvokeMethod<typeof Ipc.window.setTitlebar>
|
||||
runDesktopMenuAction: IpcInvokeMethod<typeof Ipc.menu.runAction>
|
||||
setBackgroundColor: IpcInvokeMethod<typeof Ipc.app.setBackgroundColor>
|
||||
exportDebugLogs: IpcInvokeMethod<typeof Ipc.app.exportDebugLogs>
|
||||
setForceFocus: IpcInvokeMethod<typeof Ipc.app.setForceFocus>
|
||||
recordFatalRendererError: IpcInvokeMethod<typeof Ipc.app.recordFatalRendererError>
|
||||
setNativeTranslations: IpcInvokeMethod<typeof Ipc.app.setNativeTranslations>
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import type {
|
||||
ClipboardImage,
|
||||
DirectoryPickerOptions,
|
||||
FatalRendererError,
|
||||
FilePickerOptions,
|
||||
PickedFiles,
|
||||
SaveFilePickerOptions,
|
||||
ServerReadyData,
|
||||
TitlebarTheme,
|
||||
} from "../shared/ipc-contract"
|
||||
|
||||
export type WslServersAPI = WslServersPlatform
|
||||
export type UpdaterAPI = {
|
||||
subscribe(cb: (state: UpdaterState) => void): Promise<() => void>
|
||||
check(): Promise<UpdaterState>
|
||||
install(): Promise<void>
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
getDefaultServerUrl(): Promise<string | null>
|
||||
setDefaultServerUrl(url: string | null): Promise<void>
|
||||
isFirstLaunchOnboardingPending(): Promise<boolean>
|
||||
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
|
||||
checkAppExists(appName: string): Promise<boolean>
|
||||
resolveAppPath(appName: string): Promise<string | null>
|
||||
storeGet(name: string, key: string): Promise<string | null>
|
||||
storeSet(name: string, key: string, value: string): Promise<void>
|
||||
storeDelete(name: string, key: string): Promise<void>
|
||||
storeClear(name: string): Promise<void>
|
||||
storeKeys(name: string): Promise<string[]>
|
||||
storeLength(name: string): Promise<number>
|
||||
draftGet(key: string): Promise<string | null>
|
||||
draftSet(key: string, value: string): Promise<void>
|
||||
draftDelete(key: string): Promise<void>
|
||||
draftBlobPut(data: ArrayBuffer): Promise<string>
|
||||
draftBlobGet(id: string): Promise<ArrayBuffer | null>
|
||||
getWindowID(): Promise<string>
|
||||
onMenuCommand(cb: (id: string) => void): () => void
|
||||
onDeepLink(cb: (urls: string[]) => void): () => void
|
||||
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
|
||||
openFilePicker(opts?: FilePickerOptions): Promise<PickedFiles | null>
|
||||
readPickedFile(token: string, path: string): Promise<ArrayBuffer>
|
||||
releasePickedFiles(token: string): Promise<void>
|
||||
getPathForFile(file: File): string
|
||||
saveFilePicker(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
openExternal(url: string): void
|
||||
openLocalFile(url: string): void
|
||||
openPath(path: string, app?: string): Promise<string | undefined>
|
||||
revealPath(path: string): Promise<boolean>
|
||||
readClipboardImage(): Promise<ClipboardImage | null>
|
||||
getWindowFocused(): Promise<boolean>
|
||||
getWindowFullscreen(): Promise<boolean>
|
||||
onWindowFullscreenChanged(cb: (fullscreen: boolean) => void): () => void
|
||||
setWindowFocus(): Promise<void>
|
||||
showWindow(): Promise<void>
|
||||
relaunch(): void
|
||||
getZoomFactor(): Promise<number>
|
||||
setZoomFactor(factor: number): Promise<void>
|
||||
getPinchZoomEnabled(): Promise<boolean>
|
||||
setPinchZoomEnabled(enabled: boolean): Promise<void>
|
||||
onPinchZoomEnabledChanged(cb: (enabled: boolean) => void): () => void
|
||||
onZoomFactorChanged(cb: (factor: number) => void): () => void
|
||||
setTitlebar(theme: TitlebarTheme): Promise<void>
|
||||
runDesktopMenuAction(action: DesktopMenuAction): Promise<void>
|
||||
setBackgroundColor(color: string): Promise<void>
|
||||
exportDebugLogs(): Promise<string>
|
||||
setForceFocus(enabled: boolean): Promise<void>
|
||||
recordFatalRendererError(error: FatalRendererError): Promise<void>
|
||||
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { ElectronAPI } from "./api-types"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import { invoke, listen, send } from "./ipc-client"
|
||||
|
||||
type Mutable<Value> =
|
||||
Value extends ReadonlyArray<unknown>
|
||||
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
|
||||
: Value extends object
|
||||
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
|
||||
: Value
|
||||
|
||||
const mutable = <Value>(value: Value) => value as Mutable<Value>
|
||||
const toArrayBuffer = (value: Uint8Array) =>
|
||||
value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer
|
||||
|
||||
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
|
||||
let updaterState: UpdaterState | undefined
|
||||
let updaterSubscription: Promise<void> | undefined
|
||||
let updaterListener: (() => void) | undefined
|
||||
const updaterHandler = (state: UpdaterState) => {
|
||||
updaterState = state
|
||||
updaterCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
export const api: ElectronAPI = {
|
||||
awaitInitialization: () => invoke("AppAwaitInitialization"),
|
||||
wslServers: {
|
||||
getState: () => invoke("WslGetState").then(mutable),
|
||||
subscribe: (cb) => {
|
||||
const dispose = listen("WslServersChanged", (event) => cb(mutable(event.event)))
|
||||
void invoke("WslSubscribe")
|
||||
return () => {
|
||||
dispose()
|
||||
void invoke("WslUnsubscribe")
|
||||
}
|
||||
},
|
||||
probeRuntime: () => invoke("WslProbeRuntime"),
|
||||
refreshDistros: () => invoke("WslRefreshDistros"),
|
||||
installWsl: () => invoke("WslInstallWsl"),
|
||||
installDistro: (name) => invoke("WslInstallDistro", { name }),
|
||||
probeAddable: (distros) => invoke("WslProbeAddable", { distros }),
|
||||
installOpencode: (name) => invoke("WslInstallOpencode", { name }),
|
||||
openTerminal: (name) => invoke("WslOpenTerminal", { name }),
|
||||
addServer: (distro) => invoke("WslAddServer", { distro }),
|
||||
removeServer: (id) => invoke("WslRemoveServer", { id }),
|
||||
startServer: (id) => invoke("WslStartServer", { id }),
|
||||
},
|
||||
updater: {
|
||||
subscribe: async (cb) => {
|
||||
updaterCallbacks.add(cb)
|
||||
if (updaterState) cb(updaterState)
|
||||
if (!updaterSubscription) {
|
||||
updaterListener = listen("UpdaterStateChanged", (event) => updaterHandler(mutable(event.state)))
|
||||
updaterSubscription = invoke("UpdaterSubscribe")
|
||||
}
|
||||
await updaterSubscription
|
||||
return () => {
|
||||
updaterCallbacks.delete(cb)
|
||||
if (updaterCallbacks.size > 0) return
|
||||
updaterListener?.()
|
||||
updaterListener = undefined
|
||||
updaterSubscription = undefined
|
||||
void invoke("UpdaterUnsubscribe")
|
||||
}
|
||||
},
|
||||
check: () => invoke("UpdaterCheck"),
|
||||
install: () => invoke("UpdaterInstall"),
|
||||
},
|
||||
consumeInitialDeepLinks: () => invoke("AppConsumeInitialDeepLinks").then(mutable),
|
||||
getDefaultServerUrl: () => invoke("AppGetDefaultServerUrl"),
|
||||
setDefaultServerUrl: (url) => invoke("AppSetDefaultServerUrl", { url }),
|
||||
isFirstLaunchOnboardingPending: () => invoke("AppIsFirstLaunchOnboardingPending"),
|
||||
finishFirstLaunchOnboarding: (createDefaultProject) =>
|
||||
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
|
||||
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
|
||||
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
|
||||
storeGet: (name, key) => invoke("StorageGet", { name, key }),
|
||||
storeSet: (name, key, value) => invoke("StorageSet", { name, key, value }),
|
||||
storeDelete: (name, key) => invoke("StorageDelete", { name, key }),
|
||||
storeClear: (name) => invoke("StorageClear", { name }),
|
||||
storeKeys: (name) => invoke("StorageKeys", { name }).then(mutable),
|
||||
storeLength: (name) => invoke("StorageLength", { name }),
|
||||
draftGet: (key) => invoke("DraftsGet", { key }),
|
||||
draftSet: (key, value) => invoke("DraftsSet", { key, value }),
|
||||
draftDelete: (key) => invoke("DraftsDelete", { key }),
|
||||
draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }),
|
||||
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
|
||||
|
||||
getWindowID: () => invoke("WindowGetId"),
|
||||
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
|
||||
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
|
||||
|
||||
openDirectoryPicker: (opts) => invoke("FilesOpenDirectoryPicker", { options: opts }).then(mutable),
|
||||
openFilePicker: (opts) => invoke("FilesOpenFilePicker", { options: opts }).then(mutable),
|
||||
readPickedFile: (token, path) => invoke("FilesReadPickedFile", { token, path }).then(toArrayBuffer),
|
||||
releasePickedFiles: (token) => invoke("FilesReleasePickedFiles", { token }),
|
||||
getPathForFile: (file) => window.electron.getPathForFile(file),
|
||||
saveFilePicker: (opts) => invoke("FilesSaveFilePicker", { options: opts }),
|
||||
openExternal: (url) => send("FilesOpenExternal", { url }),
|
||||
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
|
||||
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
|
||||
revealPath: (path) => invoke("FilesRevealPath", { path }),
|
||||
readClipboardImage: () =>
|
||||
invoke("FilesReadClipboardImage").then((image) =>
|
||||
image ? { ...image, buffer: toArrayBuffer(image.buffer) } : null,
|
||||
),
|
||||
getWindowFocused: () => invoke("WindowGetFocused"),
|
||||
getWindowFullscreen: () => invoke("WindowGetFullscreen"),
|
||||
onWindowFullscreenChanged: (cb) => listen("WindowFullscreenChanged", (event) => cb(event.fullscreen)),
|
||||
setWindowFocus: () => invoke("WindowSetFocus"),
|
||||
showWindow: () => invoke("WindowShow"),
|
||||
relaunch: () => send("AppRelaunch"),
|
||||
getZoomFactor: () => invoke("WindowGetZoomFactor"),
|
||||
setZoomFactor: (factor) => invoke("WindowSetZoomFactor", { factor }),
|
||||
getPinchZoomEnabled: () => invoke("WindowGetPinchZoomEnabled"),
|
||||
setPinchZoomEnabled: (enabled) => invoke("WindowSetPinchZoomEnabled", { enabled }),
|
||||
onPinchZoomEnabledChanged: (cb) => listen("WindowPinchZoomChanged", (event) => cb(event.enabled)),
|
||||
onZoomFactorChanged: (cb) => listen("WindowZoomChanged", (event) => cb(event.factor)),
|
||||
setTitlebar: (theme) => invoke("WindowSetTitlebar", { theme }),
|
||||
runDesktopMenuAction: (action) => invoke("MenuRunAction", { action }),
|
||||
setBackgroundColor: (color) => invoke("AppSetBackgroundColor", { color }),
|
||||
exportDebugLogs: () => invoke("AppExportDebugLogs"),
|
||||
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
|
||||
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
|
||||
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import type { BaseRouterProps } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||
import type { ElectronAPI } from "./api-types"
|
||||
import type { ElectronAPI } from "../preload/types"
|
||||
import { MigrationStatus } from "./migration-status"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import type { ElectronNative } from "../preload/types"
|
||||
import type { ElectronAPI } from "../preload/types"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronNative
|
||||
api: ElectronAPI
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
import { api } from "../api"
|
||||
import {
|
||||
DESKTOP_NATIVE_LOCALES,
|
||||
detectDesktopNativeLocale,
|
||||
@@ -200,7 +199,7 @@ export function initI18n(): Promise<Locale> {
|
||||
if (cached) return cached
|
||||
|
||||
const promise = (async () => {
|
||||
const raw = await api.storeGet("opencode.global.dat", "language").catch(() => null)
|
||||
const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null)
|
||||
const value = parseStored(raw)
|
||||
const next = pickLocale(value) ?? state.locale
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import "./diagnostics"
|
||||
import "./styles.css"
|
||||
import { render } from "solid-js/web"
|
||||
import { api } from "./api"
|
||||
import { DesktopApp } from "./desktop-app"
|
||||
import { startDesktopMenu } from "./platform/menu"
|
||||
import { startDesktopUpdater } from "./platform/updater"
|
||||
@@ -15,8 +14,8 @@ const root = requireRendererRoot()
|
||||
const version = desktopVersion()
|
||||
initializeSentry(version)
|
||||
|
||||
const updater = startDesktopUpdater(api)
|
||||
startDesktopMenu(api)
|
||||
startDeepLinks(api)
|
||||
const updater = startDesktopUpdater(window.api)
|
||||
startDesktopMenu(window.api)
|
||||
startDeepLinks(window.api)
|
||||
|
||||
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
|
||||
render(() => <DesktopApp api={window.api} updater={updater} version={version} />, root)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
|
||||
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
|
||||
|
||||
type EventTag = DesktopEvent["_tag"]
|
||||
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
|
||||
type InvokeArgs<Tag extends InvokeTag> = Parameters<DesktopRpcClient[Tag]>
|
||||
type InvokeResult<Tag extends InvokeTag> =
|
||||
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
|
||||
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
|
||||
|
||||
const port = new Promise<MessagePort>((resolve) => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window || event.data !== IpcTransportPort) return
|
||||
const value = event.ports[0]
|
||||
if (!value) return
|
||||
window.removeEventListener("message", onMessage)
|
||||
resolve(value)
|
||||
}
|
||||
window.addEventListener("message", onMessage)
|
||||
})
|
||||
|
||||
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
|
||||
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
|
||||
const runtime = ManagedRuntime.make(ClientLive)
|
||||
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
|
||||
|
||||
runtime.runFork(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* DesktopClient
|
||||
yield* client
|
||||
.DesktopEvents()
|
||||
.pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
|
||||
return runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* DesktopClient
|
||||
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
|
||||
return yield* method(...payload)
|
||||
}),
|
||||
) as Promise<InvokeResult<Tag>>
|
||||
}
|
||||
|
||||
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
|
||||
void invoke(tag, ...payload).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventValue<Tag>) => void) {
|
||||
const callback = listener as (value: unknown) => void
|
||||
const callbacks = listeners.get(tag) ?? new Set()
|
||||
callbacks.add(callback)
|
||||
listeners.set(tag, callbacks)
|
||||
return () => {
|
||||
callbacks.delete(callback)
|
||||
if (callbacks.size === 0) listeners.delete(tag)
|
||||
}
|
||||
}
|
||||
|
||||
function clientProtocol(value: MessagePort) {
|
||||
return Layer.effect(
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
.decode(event.data)
|
||||
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
value.addEventListener("message", onMessage)
|
||||
value.start()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
value.removeEventListener("message", onMessage)
|
||||
value.close()
|
||||
}),
|
||||
)
|
||||
yield* Stream.fromQueue(inbound).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
send: (_clientId, request) =>
|
||||
Effect.sync(() => {
|
||||
const encoded = parser.encode(request)
|
||||
if (encoded !== undefined) value.postMessage(encoded)
|
||||
}),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ServerConnection, useServers, useTabs } from "@opencode-ai/app"
|
||||
import { onMount } from "solid-js"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
export function DesktopFirstLaunchOnboarding(props: {
|
||||
api: ElectronAPI
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Platform } from "@opencode-ai/app"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
type DesktopOS = Extract<Platform, { platform: "desktop" }>["os"]
|
||||
type DesktopFileAPI = Pick<
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode-ai/app"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
|
||||
import { windowFullscreen } from "../window/fullscreen"
|
||||
import { createDesktopFiles } from "./files"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Platform } from "@opencode-ai/app"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
import { resetZoom, zoomIn, zoomOut } from "../window/zoom"
|
||||
|
||||
let trigger: ((id: string) => void) | null = null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Platform } from "@opencode-ai/app"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
|
||||
return async (title, description, onClick) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createDraftStore, type Platform } from "@opencode-ai/app"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
export function createDesktopStorage(api: ElectronAPI) {
|
||||
const cache = new Map<string, AsyncStorage>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UpdaterPlatform, UpdaterState } from "@opencode-ai/app"
|
||||
import { createSignal } from "solid-js"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
export function startDesktopUpdater(api: ElectronAPI): UpdaterPlatform {
|
||||
const [state, setState] = createSignal<UpdaterState>({ status: "disabled" })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Ipc } from "../../shared/ipc-contract"
|
||||
import { initializationData, initializationReady } from "./initialization"
|
||||
|
||||
describe("desktop renderer initialization", () => {
|
||||
@@ -14,8 +15,10 @@ describe("desktop renderer initialization", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves clean RPC startup errors", () => {
|
||||
const error = new Error("Cannot migrate session_message projections")
|
||||
test("removes Electron's remote invocation wrapper from startup errors", () => {
|
||||
const error = new Error(
|
||||
`Error invoking remote method '${Ipc.app.awaitInitialization}': Error: Cannot migrate session_message projections`,
|
||||
)
|
||||
|
||||
try {
|
||||
initializationData(Object.assign(() => undefined, { error }))
|
||||
|
||||
@@ -5,6 +5,12 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
|
||||
|
||||
function markLocalServerStartup(error: unknown) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
const prefix = `Error invoking remote method '${Ipc.app.awaitInitialization}': Error: `
|
||||
if (failure.message.startsWith(prefix)) {
|
||||
const previous = failure.message
|
||||
failure.message = failure.message.slice(prefix.length)
|
||||
if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`)
|
||||
}
|
||||
Object.defineProperty(failure, "localServerStartup", { value: true })
|
||||
return failure
|
||||
}
|
||||
@@ -14,3 +20,4 @@ export function initializationReady<A>(state: (() => A | undefined) & { error: u
|
||||
initializationData(state)
|
||||
return true
|
||||
}
|
||||
import { Ipc } from "../../shared/ipc-contract"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { api } from "../api"
|
||||
|
||||
const [windowFullscreen, setWindowFullscreen] = createSignal(false)
|
||||
|
||||
api.onWindowFullscreenChanged(setWindowFullscreen)
|
||||
void api.getWindowFullscreen().then(setWindowFullscreen)
|
||||
window.api.onWindowFullscreenChanged(setWindowFullscreen)
|
||||
void window.api.getWindowFullscreen().then(setWindowFullscreen)
|
||||
|
||||
export { windowFullscreen }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { api } from "../api"
|
||||
|
||||
const OS_NAME = (() => {
|
||||
if (navigator.userAgent.includes("Mac")) return "macos"
|
||||
@@ -34,7 +33,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z
|
||||
|
||||
const applyZoom = (next: number) => {
|
||||
requestedZoom = next
|
||||
void api
|
||||
void window.api
|
||||
.setZoomFactor(next)
|
||||
.then(() => {
|
||||
if (requestedZoom !== next) return
|
||||
@@ -46,16 +45,16 @@ const applyZoom = (next: number) => {
|
||||
})
|
||||
}
|
||||
|
||||
api.onZoomFactorChanged((factor) => {
|
||||
window.api.onZoomFactorChanged((factor) => {
|
||||
requestedZoom = clamp(factor)
|
||||
setWebviewZoom(requestedZoom)
|
||||
})
|
||||
|
||||
void api.getPinchZoomEnabled().then((enabled) => {
|
||||
void window.api.getPinchZoomEnabled().then((enabled) => {
|
||||
pinchZoomEnabled = enabled
|
||||
})
|
||||
|
||||
api.onPinchZoomEnabledChanged((enabled) => {
|
||||
window.api.onPinchZoomEnabledChanged((enabled) => {
|
||||
pinchZoomEnabled = enabled
|
||||
resetWheelPinch()
|
||||
})
|
||||
@@ -63,7 +62,7 @@ api.onPinchZoomEnabledChanged((enabled) => {
|
||||
const setPinchZoomEnabled = (enabled: boolean) => {
|
||||
pinchZoomEnabled = enabled
|
||||
resetWheelPinch()
|
||||
return api.setPinchZoomEnabled(enabled)
|
||||
return window.api.setPinchZoomEnabled(enabled)
|
||||
}
|
||||
|
||||
const resetZoom = () => applyZoom(1)
|
||||
|
||||
@@ -1,3 +1,97 @@
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { WslServerConfig, WslServersEvent, WslServersState } from "@opencode-ai/app/wsl/types"
|
||||
|
||||
export const Ipc = {
|
||||
app: {
|
||||
awaitInitialization: "await-initialization",
|
||||
consumeInitialDeepLinks: "consume-initial-deep-links",
|
||||
deepLink: "deep-link",
|
||||
getDefaultServerUrl: "get-default-server-url",
|
||||
setDefaultServerUrl: "set-default-server-url",
|
||||
isFirstLaunchOnboardingPending: "is-first-launch-onboarding-pending",
|
||||
finishFirstLaunchOnboarding: "finish-first-launch-onboarding",
|
||||
checkAppExists: "check-app-exists",
|
||||
resolveAppPath: "resolve-app-path",
|
||||
relaunch: "relaunch",
|
||||
setBackgroundColor: "set-background-color",
|
||||
exportDebugLogs: "export-debug-logs",
|
||||
setForceFocus: "set-force-focus",
|
||||
recordFatalRendererError: "record-fatal-renderer-error",
|
||||
setNativeTranslations: "set-native-translations",
|
||||
},
|
||||
storage: {
|
||||
get: "store-get",
|
||||
set: "store-set",
|
||||
delete: "store-delete",
|
||||
clear: "store-clear",
|
||||
keys: "store-keys",
|
||||
length: "store-length",
|
||||
},
|
||||
drafts: {
|
||||
get: "draft-get",
|
||||
set: "draft-set",
|
||||
delete: "draft-delete",
|
||||
putBlob: "draft-blob-put",
|
||||
getBlob: "draft-blob-get",
|
||||
},
|
||||
files: {
|
||||
openDirectoryPicker: "open-directory-picker",
|
||||
openFilePicker: "open-file-picker",
|
||||
readPickedFile: "read-picked-file",
|
||||
releasePickedFiles: "release-picked-files",
|
||||
saveFilePicker: "save-file-picker",
|
||||
openExternal: "open-external",
|
||||
openLocalFile: "open-local-file",
|
||||
openPath: "open-path",
|
||||
revealPath: "reveal-path",
|
||||
readClipboardImage: "read-clipboard-image",
|
||||
},
|
||||
window: {
|
||||
getId: "get-window-id",
|
||||
getFocused: "get-window-focused",
|
||||
getFullscreen: "get-window-fullscreen",
|
||||
fullscreenChanged: "window-fullscreen-changed",
|
||||
setFocus: "set-window-focus",
|
||||
show: "show-window",
|
||||
getZoomFactor: "get-zoom-factor",
|
||||
setZoomFactor: "set-zoom-factor",
|
||||
zoomFactorChanged: "zoom-factor-changed",
|
||||
getPinchZoomEnabled: "get-pinch-zoom-enabled",
|
||||
setPinchZoomEnabled: "set-pinch-zoom-enabled",
|
||||
pinchZoomEnabledChanged: "pinch-zoom-enabled-changed",
|
||||
setTitlebar: "set-titlebar",
|
||||
},
|
||||
menu: {
|
||||
command: "menu-command",
|
||||
runAction: "run-desktop-menu-action",
|
||||
},
|
||||
updater: {
|
||||
subscribe: "updater-subscribe",
|
||||
unsubscribe: "updater-unsubscribe",
|
||||
check: "updater-check",
|
||||
install: "updater-install",
|
||||
state: "updater-state",
|
||||
},
|
||||
wsl: {
|
||||
subscribe: "wsl-servers-subscribe",
|
||||
unsubscribe: "wsl-servers-unsubscribe",
|
||||
getState: "wsl-servers-get-state",
|
||||
probeRuntime: "wsl-servers-probe-runtime",
|
||||
refreshDistros: "wsl-servers-refresh-distros",
|
||||
installWsl: "wsl-servers-install-wsl",
|
||||
installDistro: "wsl-servers-install-distro",
|
||||
probeAddable: "wsl-servers-probe-addable",
|
||||
installOpencode: "wsl-servers-install-opencode",
|
||||
openTerminal: "wsl-servers-open-terminal",
|
||||
addServer: "wsl-servers-add",
|
||||
removeServer: "wsl-servers-remove",
|
||||
startServer: "wsl-servers-start",
|
||||
event: "wsl-servers-event",
|
||||
},
|
||||
} as const
|
||||
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
username: string | null
|
||||
@@ -42,3 +136,112 @@ export type ClipboardImage = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type IpcInvoke = {
|
||||
[Ipc.app.awaitInitialization]: { args: []; result: ServerReadyData }
|
||||
[Ipc.app.consumeInitialDeepLinks]: { args: []; result: string[] }
|
||||
[Ipc.app.getDefaultServerUrl]: { args: []; result: string | null }
|
||||
[Ipc.app.setDefaultServerUrl]: { args: [url: string | null]; result: void }
|
||||
[Ipc.app.isFirstLaunchOnboardingPending]: { args: []; result: boolean }
|
||||
[Ipc.app.finishFirstLaunchOnboarding]: { args: [createDefaultProject: boolean]; result: string | null }
|
||||
[Ipc.app.checkAppExists]: { args: [appName: string]; result: boolean }
|
||||
[Ipc.app.resolveAppPath]: { args: [appName: string]; result: string | null }
|
||||
[Ipc.app.setBackgroundColor]: { args: [color: string]; result: void }
|
||||
[Ipc.app.exportDebugLogs]: { args: []; result: string }
|
||||
[Ipc.app.setForceFocus]: { args: [enabled: boolean]; result: void }
|
||||
[Ipc.app.recordFatalRendererError]: { args: [error: FatalRendererError]; result: void }
|
||||
[Ipc.app.setNativeTranslations]: { args: [bundle: DesktopNativeBundle]; result: void }
|
||||
|
||||
[Ipc.storage.get]: { args: [name: string, key: string]; result: string | null }
|
||||
[Ipc.storage.set]: { args: [name: string, key: string, value: string]; result: void }
|
||||
[Ipc.storage.delete]: { args: [name: string, key: string]; result: void }
|
||||
[Ipc.storage.clear]: { args: [name: string]; result: void }
|
||||
[Ipc.storage.keys]: { args: [name: string]; result: string[] }
|
||||
[Ipc.storage.length]: { args: [name: string]; result: number }
|
||||
|
||||
[Ipc.drafts.get]: { args: [key: string]; result: string | null }
|
||||
[Ipc.drafts.set]: { args: [key: string, value: string]; result: void }
|
||||
[Ipc.drafts.delete]: { args: [key: string]; result: void }
|
||||
[Ipc.drafts.putBlob]: { args: [data: ArrayBuffer]; result: string }
|
||||
[Ipc.drafts.getBlob]: { args: [id: string]; result: ArrayBuffer | null }
|
||||
|
||||
[Ipc.files.openDirectoryPicker]: {
|
||||
args: [options?: DirectoryPickerOptions]
|
||||
result: string | string[] | null
|
||||
}
|
||||
[Ipc.files.openFilePicker]: { args: [options?: FilePickerOptions]; result: PickedFiles | null }
|
||||
[Ipc.files.readPickedFile]: { args: [token: string, path: string]; result: ArrayBuffer }
|
||||
[Ipc.files.releasePickedFiles]: { args: [token: string]; result: void }
|
||||
[Ipc.files.saveFilePicker]: { args: [options?: SaveFilePickerOptions]; result: string | null }
|
||||
[Ipc.files.openPath]: { args: [path: string, app?: string]; result: string | undefined }
|
||||
[Ipc.files.revealPath]: { args: [path: string]; result: boolean }
|
||||
[Ipc.files.readClipboardImage]: { args: []; result: ClipboardImage | null }
|
||||
|
||||
[Ipc.window.getId]: { args: []; result: string }
|
||||
[Ipc.window.getFocused]: { args: []; result: boolean }
|
||||
[Ipc.window.getFullscreen]: { args: []; result: boolean }
|
||||
[Ipc.window.setFocus]: { args: []; result: void }
|
||||
[Ipc.window.show]: { args: []; result: void }
|
||||
[Ipc.window.getZoomFactor]: { args: []; result: number }
|
||||
[Ipc.window.setZoomFactor]: { args: [factor: number]; result: void }
|
||||
[Ipc.window.getPinchZoomEnabled]: { args: []; result: boolean }
|
||||
[Ipc.window.setPinchZoomEnabled]: { args: [enabled: boolean]; result: void }
|
||||
[Ipc.window.setTitlebar]: { args: [theme: TitlebarTheme]; result: void }
|
||||
[Ipc.menu.runAction]: { args: [action: DesktopMenuAction]; result: void }
|
||||
|
||||
[Ipc.updater.subscribe]: { args: []; result: void }
|
||||
[Ipc.updater.unsubscribe]: { args: []; result: void }
|
||||
[Ipc.updater.check]: { args: []; result: UpdaterState }
|
||||
[Ipc.updater.install]: { args: []; result: void }
|
||||
|
||||
[Ipc.wsl.subscribe]: { args: []; result: void }
|
||||
[Ipc.wsl.unsubscribe]: { args: []; result: void }
|
||||
[Ipc.wsl.getState]: { args: []; result: WslServersState }
|
||||
[Ipc.wsl.probeRuntime]: { args: []; result: void }
|
||||
[Ipc.wsl.refreshDistros]: { args: []; result: void }
|
||||
[Ipc.wsl.installWsl]: { args: []; result: void }
|
||||
[Ipc.wsl.installDistro]: { args: [name: string]; result: void }
|
||||
[Ipc.wsl.probeAddable]: { args: [distros: string[]]; result: void }
|
||||
[Ipc.wsl.installOpencode]: { args: [name: string]; result: void }
|
||||
[Ipc.wsl.openTerminal]: { args: [name: string]; result: void }
|
||||
[Ipc.wsl.addServer]: { args: [distro: string]; result: WslServerConfig }
|
||||
[Ipc.wsl.removeServer]: { args: [id: string]; result: void }
|
||||
[Ipc.wsl.startServer]: { args: [id: string]; result: void }
|
||||
}
|
||||
|
||||
export type IpcSend = {
|
||||
[Ipc.app.relaunch]: []
|
||||
[Ipc.files.openExternal]: [url: string]
|
||||
[Ipc.files.openLocalFile]: [url: string]
|
||||
}
|
||||
|
||||
export type IpcEvent = {
|
||||
[Ipc.app.deepLink]: [urls: string[]]
|
||||
[Ipc.menu.command]: [id: string]
|
||||
[Ipc.updater.state]: [state: UpdaterState]
|
||||
[Ipc.wsl.event]: [event: WslServersEvent]
|
||||
[Ipc.window.fullscreenChanged]: [fullscreen: boolean]
|
||||
[Ipc.window.pinchZoomEnabledChanged]: [enabled: boolean]
|
||||
[Ipc.window.zoomFactorChanged]: [factor: number]
|
||||
}
|
||||
|
||||
export type IpcInvokeArgs<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["args"]
|
||||
export type IpcInvokeResult<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["result"]
|
||||
export type IpcInvokeMethod<Channel extends keyof IpcInvoke> = (
|
||||
...args: IpcInvokeArgs<Channel>
|
||||
) => Promise<IpcInvokeResult<Channel>>
|
||||
export type IpcSendMethod<Channel extends keyof IpcSend> = (...args: IpcSend[Channel]) => void
|
||||
export type IpcEventListener<Channel extends keyof IpcEvent> = (...args: IpcEvent[Channel]) => void
|
||||
export type IpcEventSubscription<Channel extends keyof IpcEvent> = (listener: IpcEventListener<Channel>) => () => void
|
||||
|
||||
type IpcEventSender = {
|
||||
send<Channel extends keyof IpcEvent>(channel: Channel, ...args: IpcEvent[Channel]): void
|
||||
}
|
||||
|
||||
export function sendIpcEvent<Channel extends keyof IpcEvent>(
|
||||
sender: IpcEventSender,
|
||||
channel: Channel,
|
||||
...args: IpcEvent[Channel]
|
||||
) {
|
||||
sender.send(channel, ...args)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { RpcClient, RpcClientError } from "effect/unstable/rpc"
|
||||
import { AppRpcs } from "./ipc-rpc/app"
|
||||
import { EventRpcs } from "./ipc-rpc/events"
|
||||
import { FileRpcs } from "./ipc-rpc/files"
|
||||
import { MenuRpcs } from "./ipc-rpc/menu"
|
||||
import { StorageRpcs } from "./ipc-rpc/storage"
|
||||
import { UpdaterRpcs } from "./ipc-rpc/updater"
|
||||
import { WindowRpcs } from "./ipc-rpc/window"
|
||||
import { WslRpcs } from "./ipc-rpc/wsl"
|
||||
|
||||
export { AppRpcs } from "./ipc-rpc/app"
|
||||
export { EventRpcs } from "./ipc-rpc/events"
|
||||
export { FileRpcs } from "./ipc-rpc/files"
|
||||
export { MenuRpcs } from "./ipc-rpc/menu"
|
||||
export { StorageRpcs } from "./ipc-rpc/storage"
|
||||
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 type DesktopRpcClient = RpcClient.FromGroup<typeof DesktopRpcs, RpcClientError.RpcClientError>
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
const ServerReadyData = Schema.Struct({
|
||||
url: Schema.String,
|
||||
username: Schema.NullOr(Schema.String),
|
||||
password: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
|
||||
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
|
||||
success: Schema.Array(Schema.String),
|
||||
})
|
||||
export const AppGetDefaultServerUrl = Rpc.make("AppGetDefaultServerUrl", {
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const AppSetDefaultServerUrl = Rpc.make("AppSetDefaultServerUrl", {
|
||||
payload: { url: Schema.NullOr(Schema.String) },
|
||||
})
|
||||
export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboardingPending", {
|
||||
success: Schema.Boolean,
|
||||
})
|
||||
export const AppFinishFirstLaunchOnboarding = Rpc.make("AppFinishFirstLaunchOnboarding", {
|
||||
payload: { createDefaultProject: Schema.Boolean },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const AppCheckAppExists = Rpc.make("AppCheckAppExists", {
|
||||
payload: { appName: Schema.String },
|
||||
success: Schema.Boolean,
|
||||
})
|
||||
export const AppResolveAppPath = Rpc.make("AppResolveAppPath", {
|
||||
payload: { appName: Schema.String },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const AppSetBackgroundColor = Rpc.make("AppSetBackgroundColor", {
|
||||
payload: { color: Schema.String },
|
||||
})
|
||||
export const AppExportDebugLogs = Rpc.make("AppExportDebugLogs", { success: Schema.String })
|
||||
export const AppSetForceFocus = Rpc.make("AppSetForceFocus", {
|
||||
payload: { enabled: Schema.Boolean },
|
||||
})
|
||||
export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError", {
|
||||
payload: {
|
||||
error: Schema.Struct({
|
||||
error: Schema.String,
|
||||
url: Schema.String,
|
||||
version: Schema.optionalKey(Schema.String),
|
||||
platform: Schema.String,
|
||||
os: Schema.optionalKey(Schema.String),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
|
||||
payload: { value: Schema.Unknown },
|
||||
})
|
||||
export const AppRelaunch = Rpc.make("AppRelaunch")
|
||||
export const AppRpcs = RpcGroup.make(
|
||||
AppAwaitInitialization,
|
||||
AppConsumeInitialDeepLinks,
|
||||
AppGetDefaultServerUrl,
|
||||
AppSetDefaultServerUrl,
|
||||
AppIsFirstLaunchOnboardingPending,
|
||||
AppFinishFirstLaunchOnboarding,
|
||||
AppCheckAppExists,
|
||||
AppResolveAppPath,
|
||||
AppSetBackgroundColor,
|
||||
AppExportDebugLogs,
|
||||
AppSetForceFocus,
|
||||
AppRecordFatalRendererError,
|
||||
AppSetNativeTranslations,
|
||||
AppRelaunch,
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { UpdaterStateSchema } from "./updater"
|
||||
import { WslServersEventSchema } from "./wsl"
|
||||
|
||||
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
|
||||
urls: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class MenuCommandTriggered extends Schema.TaggedClass<MenuCommandTriggered>()("MenuCommandTriggered", {
|
||||
id: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class UpdaterStateChanged extends Schema.TaggedClass<UpdaterStateChanged>()("UpdaterStateChanged", {
|
||||
state: UpdaterStateSchema,
|
||||
}) {}
|
||||
|
||||
export class WslServersChanged extends Schema.TaggedClass<WslServersChanged>()("WslServersChanged", {
|
||||
event: WslServersEventSchema,
|
||||
}) {}
|
||||
|
||||
export class WindowFullscreenChanged extends Schema.TaggedClass<WindowFullscreenChanged>()("WindowFullscreenChanged", {
|
||||
fullscreen: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export class WindowPinchZoomChanged extends Schema.TaggedClass<WindowPinchZoomChanged>()("WindowPinchZoomChanged", {
|
||||
enabled: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("WindowZoomChanged", {
|
||||
factor: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export const DesktopEvent = Schema.Union([
|
||||
DeepLinksOpened,
|
||||
MenuCommandTriggered,
|
||||
UpdaterStateChanged,
|
||||
WslServersChanged,
|
||||
WindowFullscreenChanged,
|
||||
WindowPinchZoomChanged,
|
||||
WindowZoomChanged,
|
||||
])
|
||||
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
|
||||
|
||||
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents)
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
const OptionalString = Schema.optionalKey(Schema.String)
|
||||
const PickerOptions = Schema.Struct({
|
||||
multiple: Schema.optionalKey(Schema.Boolean),
|
||||
title: OptionalString,
|
||||
defaultPath: OptionalString,
|
||||
})
|
||||
const FilePickerOptions = Schema.Struct({
|
||||
multiple: Schema.optionalKey(Schema.Boolean),
|
||||
title: OptionalString,
|
||||
defaultPath: OptionalString,
|
||||
extensions: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
})
|
||||
const SavePickerOptions = Schema.Struct({ title: OptionalString, defaultPath: OptionalString })
|
||||
const PickedFiles = Schema.Struct({
|
||||
token: Schema.String,
|
||||
files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })),
|
||||
})
|
||||
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
|
||||
|
||||
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
|
||||
payload: { options: Schema.optionalKey(PickerOptions) },
|
||||
success: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
|
||||
})
|
||||
export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
|
||||
payload: { options: Schema.optionalKey(FilePickerOptions) },
|
||||
success: Schema.NullOr(PickedFiles),
|
||||
})
|
||||
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
|
||||
payload: { token: Schema.String, path: Schema.String },
|
||||
success: Schema.Uint8Array,
|
||||
})
|
||||
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
|
||||
payload: { token: Schema.String },
|
||||
})
|
||||
export const FilesSaveFilePicker = Rpc.make("FilesSaveFilePicker", {
|
||||
payload: { options: Schema.optionalKey(SavePickerOptions) },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
|
||||
payload: { url: Schema.String },
|
||||
})
|
||||
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
|
||||
payload: { url: Schema.String },
|
||||
})
|
||||
export const FilesOpenPath = Rpc.make("FilesOpenPath", {
|
||||
payload: { path: Schema.String, application: Schema.optionalKey(Schema.String) },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const FilesRevealPath = Rpc.make("FilesRevealPath", {
|
||||
payload: { path: Schema.String },
|
||||
success: Schema.Boolean,
|
||||
})
|
||||
export const FilesReadClipboardImage = Rpc.make("FilesReadClipboardImage", {
|
||||
success: Schema.NullOr(ClipboardImage),
|
||||
})
|
||||
|
||||
export const FileRpcs = RpcGroup.make(
|
||||
FilesOpenDirectoryPicker,
|
||||
FilesOpenFilePicker,
|
||||
FilesReadPickedFile,
|
||||
FilesReleasePickedFiles,
|
||||
FilesSaveFilePicker,
|
||||
FilesOpenExternal,
|
||||
FilesOpenLocalFile,
|
||||
FilesOpenPath,
|
||||
FilesRevealPath,
|
||||
FilesReadClipboardImage,
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
const DesktopMenuAction = Schema.Literals([
|
||||
"app.checkForUpdates",
|
||||
"app.relaunch",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"edit.cut",
|
||||
"edit.copy",
|
||||
"edit.paste",
|
||||
"edit.delete",
|
||||
"edit.selectAll",
|
||||
"view.reload",
|
||||
"view.toggleDevTools",
|
||||
"view.resetZoom",
|
||||
"view.zoomIn",
|
||||
"view.zoomOut",
|
||||
"view.toggleFullscreen",
|
||||
"window.new",
|
||||
"window.close",
|
||||
"window.minimize",
|
||||
"window.toggleMaximize",
|
||||
])
|
||||
|
||||
export const MenuRunAction = Rpc.make("MenuRunAction", {
|
||||
payload: { action: DesktopMenuAction },
|
||||
})
|
||||
export const MenuRpcs = RpcGroup.make(MenuRunAction)
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
export const StorageGet = Rpc.make("StorageGet", {
|
||||
payload: { name: Schema.String, key: Schema.String },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const StorageSet = Rpc.make("StorageSet", {
|
||||
payload: { name: Schema.String, key: Schema.String, value: Schema.String },
|
||||
})
|
||||
export const StorageDelete = Rpc.make("StorageDelete", {
|
||||
payload: { name: Schema.String, key: Schema.String },
|
||||
})
|
||||
export const StorageClear = Rpc.make("StorageClear", { payload: { name: Schema.String } })
|
||||
export const StorageKeys = Rpc.make("StorageKeys", {
|
||||
payload: { name: Schema.String },
|
||||
success: Schema.Array(Schema.String),
|
||||
})
|
||||
export const StorageLength = Rpc.make("StorageLength", {
|
||||
payload: { name: Schema.String },
|
||||
success: Schema.Number,
|
||||
})
|
||||
export const DraftsGet = Rpc.make("DraftsGet", {
|
||||
payload: { key: Schema.String },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const DraftsSet = Rpc.make("DraftsSet", {
|
||||
payload: { key: Schema.String, value: Schema.String },
|
||||
})
|
||||
export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } })
|
||||
export const DraftsPutBlob = Rpc.make("DraftsPutBlob", {
|
||||
payload: { data: Schema.Uint8Array },
|
||||
success: Schema.String,
|
||||
})
|
||||
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
|
||||
payload: { id: Schema.String },
|
||||
success: Schema.NullOr(Schema.Uint8Array),
|
||||
})
|
||||
|
||||
export const StorageRpcs = RpcGroup.make(
|
||||
StorageGet,
|
||||
StorageSet,
|
||||
StorageDelete,
|
||||
StorageClear,
|
||||
StorageKeys,
|
||||
StorageLength,
|
||||
DraftsGet,
|
||||
DraftsSet,
|
||||
DraftsDelete,
|
||||
DraftsPutBlob,
|
||||
DraftsGetBlob,
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
export const UpdaterStateSchema = Schema.Union([
|
||||
Schema.Struct({ status: Schema.Literal("disabled") }),
|
||||
Schema.Struct({ status: Schema.Literal("idle") }),
|
||||
Schema.Struct({ status: Schema.Literal("checking") }),
|
||||
Schema.Struct({ status: Schema.Literal("downloading"), version: Schema.String }),
|
||||
Schema.Struct({ status: Schema.Literal("ready"), version: Schema.String }),
|
||||
Schema.Struct({ status: Schema.Literal("up-to-date") }),
|
||||
Schema.Struct({ status: Schema.Literal("installing"), version: Schema.String }),
|
||||
Schema.Struct({ status: Schema.Literal("error"), message: Schema.String }),
|
||||
])
|
||||
|
||||
export const UpdaterSubscribe = Rpc.make("UpdaterSubscribe")
|
||||
export const UpdaterUnsubscribe = Rpc.make("UpdaterUnsubscribe")
|
||||
export const UpdaterCheck = Rpc.make("UpdaterCheck", { success: UpdaterStateSchema })
|
||||
export const UpdaterInstall = Rpc.make("UpdaterInstall")
|
||||
export const UpdaterRpcs = RpcGroup.make(UpdaterSubscribe, UpdaterUnsubscribe, UpdaterCheck, UpdaterInstall)
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String })
|
||||
export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean })
|
||||
export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean })
|
||||
export const WindowSetFocus = Rpc.make("WindowSetFocus")
|
||||
export const WindowShow = Rpc.make("WindowShow")
|
||||
export const WindowGetZoomFactor = Rpc.make("WindowGetZoomFactor", { success: Schema.Number })
|
||||
export const WindowSetZoomFactor = Rpc.make("WindowSetZoomFactor", {
|
||||
payload: { factor: Schema.Number },
|
||||
})
|
||||
export const WindowGetPinchZoomEnabled = Rpc.make("WindowGetPinchZoomEnabled", {
|
||||
success: Schema.Boolean,
|
||||
})
|
||||
export const WindowSetPinchZoomEnabled = Rpc.make("WindowSetPinchZoomEnabled", {
|
||||
payload: { enabled: Schema.Boolean },
|
||||
})
|
||||
export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
|
||||
payload: {
|
||||
theme: Schema.Struct({
|
||||
mode: Schema.Literals(["light", "dark"]),
|
||||
scheme: Schema.optionalKey(Schema.Literals(["system", "light", "dark"])),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export const WindowRpcs = RpcGroup.make(
|
||||
WindowGetId,
|
||||
WindowGetFocused,
|
||||
WindowGetFullscreen,
|
||||
WindowSetFocus,
|
||||
WindowShow,
|
||||
WindowGetZoomFactor,
|
||||
WindowSetZoomFactor,
|
||||
WindowGetPinchZoomEnabled,
|
||||
WindowSetPinchZoomEnabled,
|
||||
WindowSetTitlebar,
|
||||
)
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
const WslServerConfig = Schema.Struct({ id: Schema.String, distro: Schema.String })
|
||||
const WslServerRuntime = Schema.Union([
|
||||
Schema.Struct({ kind: Schema.Literal("starting") }),
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("ready"),
|
||||
url: Schema.String,
|
||||
username: Schema.NullOr(Schema.String),
|
||||
password: Schema.NullOr(Schema.String),
|
||||
}),
|
||||
Schema.Struct({ kind: Schema.Literal("failed"), message: Schema.String }),
|
||||
Schema.Struct({ kind: Schema.Literal("stopped") }),
|
||||
])
|
||||
const WslJob = Schema.Union([
|
||||
Schema.Struct({ kind: Schema.Literal("runtime"), startedAt: Schema.Number }),
|
||||
Schema.Struct({ kind: Schema.Literal("distros"), startedAt: Schema.Number }),
|
||||
Schema.Struct({ kind: Schema.Literal("install-wsl"), startedAt: Schema.Number }),
|
||||
Schema.Struct({ kind: Schema.Literal("install-distro"), distro: Schema.String, startedAt: Schema.Number }),
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("probe-addable"),
|
||||
distros: Schema.Array(Schema.String),
|
||||
startedAt: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({ kind: Schema.Literal("install-opencode"), distro: Schema.String, startedAt: Schema.Number }),
|
||||
])
|
||||
const WslServersState = Schema.Struct({
|
||||
runtime: Schema.NullOr(
|
||||
Schema.Struct({
|
||||
available: Schema.Boolean,
|
||||
version: Schema.NullOr(Schema.String),
|
||||
error: Schema.NullOr(Schema.String),
|
||||
}),
|
||||
),
|
||||
installed: Schema.Array(
|
||||
Schema.Struct({ name: Schema.String, version: Schema.NullOr(Schema.Number), isDefault: Schema.Boolean }),
|
||||
),
|
||||
online: Schema.Array(Schema.Struct({ name: Schema.String, label: Schema.String })),
|
||||
distroProbes: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
canExecute: Schema.Boolean,
|
||||
hasBash: Schema.Boolean,
|
||||
hasCurl: Schema.Boolean,
|
||||
error: Schema.NullOr(Schema.String),
|
||||
}),
|
||||
),
|
||||
opencodeChecks: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
distro: Schema.String,
|
||||
resolvedPath: Schema.NullOr(Schema.String),
|
||||
version: Schema.NullOr(Schema.String),
|
||||
expectedVersion: Schema.NullOr(Schema.String),
|
||||
matchesDesktop: Schema.NullOr(Schema.Boolean),
|
||||
error: Schema.NullOr(Schema.String),
|
||||
}),
|
||||
),
|
||||
pendingRestart: Schema.Boolean,
|
||||
servers: Schema.Array(Schema.Struct({ config: WslServerConfig, runtime: WslServerRuntime })),
|
||||
job: Schema.NullOr(WslJob),
|
||||
})
|
||||
export const WslServersEventSchema = Schema.Struct({ type: Schema.Literal("state"), state: WslServersState })
|
||||
|
||||
export const WslSubscribe = Rpc.make("WslSubscribe")
|
||||
export const WslUnsubscribe = Rpc.make("WslUnsubscribe")
|
||||
export const WslGetState = Rpc.make("WslGetState", { success: WslServersState })
|
||||
export const WslProbeRuntime = Rpc.make("WslProbeRuntime")
|
||||
export const WslRefreshDistros = Rpc.make("WslRefreshDistros")
|
||||
export const WslInstallWsl = Rpc.make("WslInstallWsl")
|
||||
export const WslInstallDistro = Rpc.make("WslInstallDistro", {
|
||||
payload: { name: Schema.String },
|
||||
})
|
||||
export const WslProbeAddable = Rpc.make("WslProbeAddable", {
|
||||
payload: { distros: Schema.Array(Schema.String) },
|
||||
})
|
||||
export const WslInstallOpencode = Rpc.make("WslInstallOpencode", {
|
||||
payload: { name: Schema.String },
|
||||
})
|
||||
export const WslOpenTerminal = Rpc.make("WslOpenTerminal", {
|
||||
payload: { name: Schema.String },
|
||||
})
|
||||
export const WslAddServer = Rpc.make("WslAddServer", {
|
||||
payload: { distro: Schema.String },
|
||||
success: WslServerConfig,
|
||||
})
|
||||
export const WslRemoveServer = Rpc.make("WslRemoveServer", {
|
||||
payload: { id: Schema.String },
|
||||
})
|
||||
export const WslStartServer = Rpc.make("WslStartServer", {
|
||||
payload: { id: Schema.String },
|
||||
})
|
||||
export const WslRpcs = RpcGroup.make(
|
||||
WslSubscribe,
|
||||
WslUnsubscribe,
|
||||
WslGetState,
|
||||
WslProbeRuntime,
|
||||
WslRefreshDistros,
|
||||
WslInstallWsl,
|
||||
WslInstallDistro,
|
||||
WslProbeAddable,
|
||||
WslInstallOpencode,
|
||||
WslOpenTerminal,
|
||||
WslAddServer,
|
||||
WslRemoveServer,
|
||||
WslStartServer,
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
export const IpcTransportPort = "desktop-rpc-port"
|
||||
@@ -32,9 +32,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.3",
|
||||
"@opentui/keymap": ">=0.5.3",
|
||||
"@opentui/solid": ">=0.5.3",
|
||||
"@opentui/core": ">=0.5.4",
|
||||
"@opentui/keymap": ">=0.5.4",
|
||||
"@opentui/solid": ">=0.5.4",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -99,7 +99,7 @@ function View(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.home-footer",
|
||||
id: "opencode.home.footer",
|
||||
setup(context) {
|
||||
// Root takeover: an external plugin replacing home.footer wins (last-
|
||||
// enabled) and this builtin shows as suppressed, not silently gone.
|
||||
|
||||
@@ -83,7 +83,7 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.prompt-footer",
|
||||
id: "opencode.prompt.footer",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "prompt.footer",
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-context",
|
||||
id: "opencode.sidebar.context",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
|
||||
@@ -40,7 +40,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar-footer",
|
||||
id: "opencode.sidebar.footer",
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
|
||||
@@ -71,7 +71,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-mcp",
|
||||
id: "opencode.sidebar.mcp",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
|
||||
@@ -1079,7 +1079,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
setup(context) {
|
||||
context.ui.router.register({
|
||||
name: ROUTE,
|
||||
|
||||
@@ -29,7 +29,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { mode: "working", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
@@ -207,7 +207,7 @@ async function renderDiffViewer(
|
||||
navigate(destination: Destination) {
|
||||
setCurrent(
|
||||
destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
? { ...destination, id: "opencode.diffs" }
|
||||
: destination,
|
||||
)
|
||||
},
|
||||
@@ -334,7 +334,7 @@ test("branch diff source requests branch VCS diff", async () => {
|
||||
const viewer = await renderDiffViewer([], {
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
},
|
||||
@@ -342,7 +342,7 @@ test("branch diff source requests branch VCS diff", async () => {
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user