mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d3824212 | |||
| 5105913568 | |||
| 28b4cade9e | |||
| 1f61eb5ca9 | |||
| ea1ff90e42 | |||
| eb3c4c82fe |
@@ -62,7 +62,6 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -685,9 +684,8 @@
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "file:../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
@@ -6369,8 +6367,6 @@
|
||||
|
||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
|
||||
|
||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
|
||||
|
||||
@@ -437,10 +437,19 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
// Mid-conversation system messages are a native Claude API feature only for
|
||||
// Opus 4.8. Other Anthropic models intentionally use the same visible wrapped-
|
||||
// user fallback as non-Anthropic routes rather than sending a role they reject.
|
||||
const supportsNativeSystemUpdates = (request: LLMRequest) => String(request.model.id) === "claude-opus-4-8"
|
||||
// Mid-conversation system messages became available with Opus 4.8 and version
|
||||
// 5 of the other supported Claude families. Treat later family versions as
|
||||
// compatible without assuming that every Anthropic Messages model is Claude.
|
||||
const supportsNativeSystemUpdates = (request: LLMRequest) => {
|
||||
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
|
||||
String(request.model.id).toLowerCase(),
|
||||
)
|
||||
if (!match) return false
|
||||
const major = Number(match[2])
|
||||
if (match[1] !== "opus") return major >= 5
|
||||
if (major !== 4) return major >= 5
|
||||
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
|
||||
}
|
||||
|
||||
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
|
||||
const last = message.content.at(-1)
|
||||
|
||||
@@ -136,6 +136,33 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports native chronological system updates on documented and later Claude family versions", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = [
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-5-1",
|
||||
"claude-sonnet-5",
|
||||
"claude-haiku-5-1",
|
||||
"claude-fable-6",
|
||||
"anthropic/claude-mythos-7.2",
|
||||
]
|
||||
|
||||
const prepared = yield* Effect.forEach(ids, (id) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id }),
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
cache: "none",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(prepared.map((item) => item.body.messages[1]?.role)).toEqual(ids.map(() => "system"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -163,6 +190,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not infer native system update support for older or undocumented Claude families", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = [
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-9",
|
||||
"claude-haiku-4-9",
|
||||
"custom-model-7",
|
||||
]
|
||||
|
||||
const prepared = yield* Effect.forEach(ids, (id) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id }),
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
cache: "none",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(prepared.map((item) => item.body.messages.some((message) => message.role === "system"))).toEqual(
|
||||
ids.map(() => false),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-text chronological system update content before send", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const APP_READY_TIMEOUT = 30_000
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { Project } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "./directory-picker-domain"
|
||||
import "./dialog-select-directory-v2.css"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
interface DialogSelectDirectoryV2Props {
|
||||
title?: string
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
|
||||
import { formatKeybind } from "@/context/command"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
|
||||
@@ -245,7 +245,7 @@ export function nativePickerPath(path: string) {
|
||||
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
|
||||
return value
|
||||
}
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { ServerSDK } from "@/context/server-sdk"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
|
||||
@@ -173,7 +173,7 @@ beforeAll(async () => {
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/core/util/encode", () => ({
|
||||
mock.module("@opencode-ai/util/encode", () => ({
|
||||
base64Decode: (value: string) => value,
|
||||
base64Encode: (value: string) => value,
|
||||
checksum: (value: string) => value,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -12,7 +12,7 @@ import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { sameDirectory } from "@/utils/workspace"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/types"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useData } from "@/context/server"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
ProjectListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { cmp, normalizeProjectInfo } from "./utils"
|
||||
|
||||
@@ -4,8 +4,6 @@ import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
|
||||
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||
|
||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
@@ -37,6 +35,12 @@ export function parseHomeSessionIndex(sessions: SessionInfo[]) {
|
||||
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
|
||||
}
|
||||
|
||||
export function mergeHomeSessionIndex(fetched: SessionInfo[], known: SessionInfo[]) {
|
||||
return parseHomeSessionIndex([
|
||||
...new Map([...fetched, ...known].map((session) => [session.id, session] as const)).values(),
|
||||
])
|
||||
}
|
||||
|
||||
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
|
||||
return [...Map.groupBy(sessions, (session) => pathKey(session.location.directory)).values()].flatMap((items) => {
|
||||
const sorted = items.toSorted((a, b) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Agent, Config, LspStatus, Path, VcsInfo } from "@/types"
|
||||
import type { Agent, Config, LspStatus, Path, ProviderListResponse, VcsInfo } from "@/types"
|
||||
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
@@ -25,7 +24,7 @@ export type State = {
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: NormalizedProviderListResponse
|
||||
provider: ProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
mcp_ready: boolean
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
@@ -33,7 +32,7 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
|
||||
export function normalizeProviderList(
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
): NormalizedProviderListResponse {
|
||||
): ProviderListResponse {
|
||||
if (!Array.isArray(providers)) {
|
||||
return providers
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import {
|
||||
autoRespondsPermission,
|
||||
isDirectoryAutoAccepting,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export function acceptKey(sessionID: string, directory?: string) {
|
||||
if (!directory) return sessionID
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Persist } from "@/utils/persist"
|
||||
import type { Platform } from "./platform"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
||||
import { useWorkspaceLocation, type LocationContext } from "./location"
|
||||
import type { Platform } from "./platform"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ProviderListResponse } from "@/types"
|
||||
import { selectProviderCatalog } from "./provider-catalog"
|
||||
|
||||
const catalog = (id: string): NormalizedProviderListResponse => ({
|
||||
const catalog = (id: string): ProviderListResponse => ({
|
||||
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
||||
connected: [id],
|
||||
default: { [id]: `${id}-model` },
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ProviderListResponse } from "@/types"
|
||||
|
||||
export const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||
export const emptyProviderCatalog: ProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||
|
||||
type DirectoryCatalog = {
|
||||
ready: boolean
|
||||
providers: NormalizedProviderListResponse
|
||||
providers: ProviderListResponse
|
||||
}
|
||||
|
||||
type ProviderCatalogInput =
|
||||
@@ -17,7 +17,7 @@ type ProviderCatalogInput =
|
||||
explicit: false
|
||||
directory?: string
|
||||
catalog?: DirectoryCatalog
|
||||
global: NormalizedProviderListResponse
|
||||
global: ProviderListResponse
|
||||
}
|
||||
|
||||
export function selectProviderCatalog(input: ProviderCatalogInput) {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
homeSessionIndexKey,
|
||||
loadHomeSessionIndex,
|
||||
parseHomeSessionIndex,
|
||||
mergeHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
@@ -53,19 +52,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const ctx = home.server.focusedContext()
|
||||
const conn = home.server.focused()
|
||||
return {
|
||||
queryKey: conn
|
||||
? homeSessionIndexKey(ServerConnection.key(conn))
|
||||
: (["home", "session-index", "unselected"] as const),
|
||||
queryKey: ["home-sessions", conn] as const,
|
||||
enabled: !!ctx && ctx.sdk.connection.status() === "connected",
|
||||
queryFn: ctx
|
||||
? async ({ signal }) => {
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.api.session.list(input, options),
|
||||
signal,
|
||||
)
|
||||
index.forEach(ctx.data.session.remember)
|
||||
return Date.now()
|
||||
}
|
||||
? ({ signal }) => loadHomeSessionIndex((input, options) => ctx.sdk.api.session.list(input, options), signal)
|
||||
: skipToken,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
@@ -76,7 +66,11 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const indexedSessions = createMemo(() => {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return []
|
||||
return retainHomeSessions(parseHomeSessionIndex(ctx.data.session.list()), HOME_SESSION_LIMIT, Date.now())
|
||||
return retainHomeSessions(
|
||||
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
)
|
||||
})
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { ServerConnection } from "@/context/servers"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
import type { FilePart } from "@/types"
|
||||
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
|
||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLayout } from "@/context/layout"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const useSessionKey = () => {
|
||||
const params = useParams()
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
partDefaultOpen,
|
||||
type UserActions,
|
||||
} from "@opencode-ai/session-ui/message-part"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolPart } from "@/types"
|
||||
import {
|
||||
presentAssistantMessage,
|
||||
presentAssistantContent,
|
||||
@@ -56,9 +56,7 @@ export function CurrentAssistantContent(props: {
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
const message = createMemo(() => presentAssistantMessage(props.sessionID, props.parentID, props.message))
|
||||
const part = createMemo(() =>
|
||||
presentAssistantContent(props.sessionID, props.message, props.contentID, props.content),
|
||||
)
|
||||
const part = createMemo(() => presentAssistantContent(props.sessionID, props.message, props.contentID, props.content))
|
||||
return (
|
||||
<Show when={part()}>
|
||||
{(part) => (
|
||||
@@ -113,9 +111,5 @@ export function currentPartDefaultOpen(
|
||||
shellExpanded: boolean,
|
||||
editExpanded: boolean,
|
||||
) {
|
||||
return partDefaultOpen(
|
||||
presentAssistantContent(sessionID, message, contentID, content),
|
||||
shellExpanded,
|
||||
editExpanded,
|
||||
)
|
||||
return partDefaultOpen(presentAssistantContent(sessionID, message, contentID, content), shellExpanded, editExpanded)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@openc
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type { Project } from "@/types"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
|
||||
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
|
||||
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
|
||||
worktree: string
|
||||
@@ -257,9 +256,83 @@ export type Agent = {
|
||||
steps?: number
|
||||
}
|
||||
|
||||
export type Provider = NormalizedProviderListResponse["all"] extends Map<string, infer Item> ? Item : never
|
||||
export type Model = Provider["models"][string]
|
||||
export type ProviderListResponse = NormalizedProviderListResponse
|
||||
export type Model = {
|
||||
id: string
|
||||
providerID: string
|
||||
api: {
|
||||
id: string
|
||||
url: string
|
||||
npm: string
|
||||
}
|
||||
name: string
|
||||
family?: string
|
||||
capabilities: {
|
||||
temperature: boolean
|
||||
reasoning: boolean
|
||||
attachment: boolean
|
||||
toolcall: boolean
|
||||
input: {
|
||||
text: boolean
|
||||
audio: boolean
|
||||
image: boolean
|
||||
video: boolean
|
||||
pdf: boolean
|
||||
}
|
||||
output: {
|
||||
text: boolean
|
||||
audio: boolean
|
||||
image: boolean
|
||||
video: boolean
|
||||
pdf: boolean
|
||||
}
|
||||
interleaved: boolean | { field: "reasoning" | "reasoning_content" | "reasoning_details" }
|
||||
}
|
||||
cost: {
|
||||
input: number
|
||||
output: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
tiers?: {
|
||||
input: number
|
||||
output: number
|
||||
cache: { read: number; write: number }
|
||||
tier: { type: "context"; size: number }
|
||||
}[]
|
||||
experimentalOver200K?: {
|
||||
input: number
|
||||
output: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
limit: {
|
||||
context: number
|
||||
input?: number
|
||||
output: number
|
||||
}
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
options: Record<string, unknown>
|
||||
headers: Record<string, string>
|
||||
release_date: string
|
||||
variants?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
id: string
|
||||
name: string
|
||||
source: "env" | "config" | "custom" | "api"
|
||||
env: string[]
|
||||
key?: string
|
||||
options: Record<string, unknown>
|
||||
models: Record<string, Model>
|
||||
}
|
||||
|
||||
export type ProviderListResponse = {
|
||||
all: Map<string, Provider>
|
||||
default: Record<string, string>
|
||||
connected: string[]
|
||||
}
|
||||
|
||||
export type ProviderAuthResponse = Record<string, unknown>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Decode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
|
||||
export function decode64(value: string | undefined) {
|
||||
if (value === undefined) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Platform, usePlatform } from "@/context/platform"
|
||||
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@/types"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createCommentMetadata, formatCommentNote, readPromptPresentation } from "./comment-note"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
|
||||
@@ -22,6 +22,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "ts-dist"],
|
||||
"references": [{ "path": "../core" }]
|
||||
"exclude": ["dist", "ts-dist"]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1125,7 +1125,7 @@ export function createData(config: CreateDataInput) {
|
||||
if (!cursor || store.session.messageLoading[sessionID]) return
|
||||
setStore("session", "messageLoading", sessionID, true)
|
||||
const response = await api()
|
||||
.message.list({ sessionID, limit: 200, order: "desc", cursor })
|
||||
.message.list({ sessionID, limit: 200, cursor })
|
||||
.finally(() => setStore("session", "messageLoading", sessionID, false))
|
||||
const older = response.data.toReversed()
|
||||
const existing = store.session.message[sessionID] ?? []
|
||||
|
||||
@@ -16,6 +16,7 @@ export const MAX_READ_BYTES = 50 * 1024
|
||||
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
|
||||
const FIRST_CHUNK = 256 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const TREE_BASE = 6
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
|
||||
|
||||
@@ -159,19 +160,59 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
}
|
||||
}
|
||||
|
||||
const chunks = [first.bytes]
|
||||
if (first.bytes.length >= first.info.size) {
|
||||
const result = textPage(first.bytes, true, page)
|
||||
if (result === undefined) return yield* Effect.die("Read page did not settle for a complete first chunk")
|
||||
return yield* makeTextPage(input, resource, result, first.bytes.subarray(0, result.consumed).includes(0))
|
||||
}
|
||||
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const leaves = [textLeaf(first.bytes)]
|
||||
let bytes = first.bytes.length
|
||||
let lines = leaves[0].summary.lines
|
||||
let ended = false
|
||||
while (true) {
|
||||
const bytes = Buffer.concat(chunks)
|
||||
const eof = bytes.length >= first.info.size
|
||||
const result = textPage(bytes, eof, page)
|
||||
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
|
||||
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
|
||||
if (next.bytes.length === 0) {
|
||||
const result = textPage(bytes, true, page)
|
||||
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
|
||||
return yield* makeTextPage(bytes, input, resource, result)
|
||||
const eof = ended || bytes >= first.info.size
|
||||
if (lines >= offset - 1 || eof) {
|
||||
const tree = textTree(leaves)
|
||||
const start = textOffset(tree, offset - 1)
|
||||
let position = 0
|
||||
const selected = Buffer.concat(
|
||||
leaves.flatMap((leaf) => {
|
||||
const leafStart = position
|
||||
position += leaf.summary.bytes
|
||||
if (position <= start) return []
|
||||
return [leaf.bytes.subarray(Math.max(0, start - leafStart))]
|
||||
}),
|
||||
)
|
||||
const result = textPage(selected, eof, { limit })
|
||||
if (result !== undefined) {
|
||||
const translated = {
|
||||
...result,
|
||||
offset,
|
||||
...(result.next === undefined ? { next: undefined } : { next: offset + result.next - 1 }),
|
||||
}
|
||||
const consumed = start + result.consumed
|
||||
let checked = 0
|
||||
const binary = leaves.some((leaf) => {
|
||||
const length = Math.min(leaf.summary.bytes, consumed - checked)
|
||||
checked += leaf.summary.bytes
|
||||
return length > 0 && leaf.bytes.subarray(0, length).includes(0)
|
||||
})
|
||||
return yield* makeTextPage(input, resource, translated, binary)
|
||||
}
|
||||
}
|
||||
chunks.push(next.bytes)
|
||||
|
||||
const next = yield* readFile(files, input, resource, { offset: bytes, length: FIRST_CHUNK })
|
||||
if (next.bytes.length === 0) {
|
||||
ended = true
|
||||
continue
|
||||
}
|
||||
const leaf = textLeaf(next.bytes)
|
||||
leaves.push(leaf)
|
||||
bytes += leaf.summary.bytes
|
||||
lines += leaf.summary.lines
|
||||
}
|
||||
})
|
||||
|
||||
@@ -188,12 +229,12 @@ const readFile = (
|
||||
)
|
||||
|
||||
const makeTextPage = Effect.fnUntraced(function* (
|
||||
bytes: Uint8Array,
|
||||
input: AbsolutePath,
|
||||
resource: string,
|
||||
result: NonNullable<ReturnType<typeof textPage>>,
|
||||
binary: boolean,
|
||||
) {
|
||||
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
|
||||
if (binary) return yield* new BinaryFileError({ resource })
|
||||
if (result.entries.length === 0 && result.offset !== 1)
|
||||
return yield* new OffsetOutOfRangeError({ offset: result.offset })
|
||||
return new TextPage({
|
||||
@@ -274,6 +315,60 @@ const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
|
||||
return { entries, offset, next, consumed }
|
||||
}
|
||||
|
||||
type TextSummary = { readonly bytes: number; readonly lines: number }
|
||||
// Request-local augmented rope. Subtree byte and newline weights locate a line
|
||||
// like an order-statistic query without repeatedly decoding the accumulated text.
|
||||
// https://doi.org/10.1002/spe.4380251203
|
||||
type TextNode =
|
||||
| { readonly type: "leaf"; readonly bytes: Uint8Array; readonly summary: TextSummary }
|
||||
| { readonly type: "branch"; readonly children: ReadonlyArray<TextNode>; readonly summary: TextSummary }
|
||||
|
||||
const textLeaf = (bytes: Uint8Array): Extract<TextNode, { readonly type: "leaf" }> => {
|
||||
let lines = 0
|
||||
for (const byte of bytes) if (byte === 10) lines++
|
||||
return { type: "leaf", bytes, summary: { bytes: bytes.length, lines } }
|
||||
}
|
||||
|
||||
const textTree = (nodes: ReadonlyArray<TextNode>): TextNode => {
|
||||
if (nodes.length === 1) return nodes[0]
|
||||
return textTree(
|
||||
Array.from({ length: Math.ceil(nodes.length / (TREE_BASE * 2)) }, (_, index) => {
|
||||
const children = nodes.slice(index * TREE_BASE * 2, (index + 1) * TREE_BASE * 2)
|
||||
return {
|
||||
type: "branch" as const,
|
||||
children,
|
||||
summary: {
|
||||
bytes: children.reduce((total, child) => total + child.summary.bytes, 0),
|
||||
lines: children.reduce((total, child) => total + child.summary.lines, 0),
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const textOffset = (tree: TextNode, newline: number) => {
|
||||
if (newline === 0) return 0
|
||||
let node = tree
|
||||
let remaining = newline
|
||||
let offset = 0
|
||||
while (node.type === "branch") {
|
||||
const child = node.children.find((candidate) => {
|
||||
if (remaining <= candidate.summary.lines) return true
|
||||
remaining -= candidate.summary.lines
|
||||
offset += candidate.summary.bytes
|
||||
return false
|
||||
})
|
||||
if (!child) return tree.summary.bytes
|
||||
node = child
|
||||
}
|
||||
for (const [index, byte] of node.bytes.entries()) {
|
||||
if (byte !== 10) continue
|
||||
remaining--
|
||||
if (remaining === 0) return offset + index + 1
|
||||
}
|
||||
return tree.summary.bytes
|
||||
}
|
||||
|
||||
const nthNewline = (bytes: Uint8Array, count: number) => {
|
||||
let found = 0
|
||||
for (const [index, byte] of bytes.entries()) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
export namespace Binary {
|
||||
export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
|
||||
let left = 0
|
||||
let right = array.length - 1
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
const midId = compare(array[mid])
|
||||
|
||||
if (midId === id) {
|
||||
return { found: true, index: mid }
|
||||
} else if (midId < id) {
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false, index: left }
|
||||
}
|
||||
|
||||
export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
|
||||
const id = compare(item)
|
||||
let left = 0
|
||||
let right = array.length
|
||||
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
const midId = compare(array[mid])
|
||||
|
||||
if (midId < id) {
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid
|
||||
}
|
||||
}
|
||||
|
||||
array.splice(left, 0, item)
|
||||
return array
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
export interface RetryOptions {
|
||||
attempts?: number
|
||||
delay?: number
|
||||
factor?: number
|
||||
maxDelay?: number
|
||||
retryIf?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
const TRANSIENT_MESSAGES = [
|
||||
"load failed",
|
||||
"network connection was lost",
|
||||
"network request failed",
|
||||
"failed to fetch",
|
||||
"econnreset",
|
||||
"econnrefused",
|
||||
"etimedout",
|
||||
"socket hang up",
|
||||
]
|
||||
|
||||
function isTransientError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
// oxlint-disable-next-line no-base-to-string -- error is unknown, intentional coercion for message matching
|
||||
const message = String(error instanceof Error ? error.message : error).toLowerCase()
|
||||
return TRANSIENT_MESSAGES.some((m) => message.includes(m))
|
||||
}
|
||||
|
||||
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
||||
const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options
|
||||
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt === attempts - 1 || !retryIf(error)) throw error
|
||||
const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay)
|
||||
await new Promise((resolve) => setTimeout(resolve, wait))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
@@ -227,6 +227,21 @@ describe("ReadToolFileSystem", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("checks skipped lines for null bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "nul-prefix.txt")
|
||||
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one"), 0, 10, 116, 119, 111, 10]))
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "nul-prefix.txt", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads page two after fetching more than the first 256KB range", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
|
||||
@@ -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, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
|
||||
import { registerIpcHandlers } from "./ipc"
|
||||
import {
|
||||
acquireApplicationLock,
|
||||
configureApplication,
|
||||
@@ -21,6 +21,7 @@ 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()
|
||||
@@ -33,6 +34,8 @@ 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()
|
||||
@@ -41,7 +44,7 @@ const main = Effect.gen(function* () {
|
||||
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||
relaunch: lifecycle.relaunch,
|
||||
}
|
||||
registerIpcHandlers({
|
||||
const ipcDeps: Parameters<typeof registerIpcHandlers>[0] = {
|
||||
relaunch: lifecycle.relaunch,
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* () {
|
||||
@@ -66,8 +69,8 @@ const main = Effect.gen(function* () {
|
||||
setNativeTranslations: (bundle) => {
|
||||
if (setNativeTranslations(bundle)) createMenu(menu)
|
||||
},
|
||||
})
|
||||
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
|
||||
}
|
||||
yield* Effect.promise(() => registerIpcHandlers(ipcDeps, updaterIpc, wslIpc.ipc))
|
||||
startAutoUpdater(updater)
|
||||
yield* Effect.promise(() => startNetworkLogging())
|
||||
|
||||
@@ -76,7 +79,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))
|
||||
registerWslIpcHandlers(wsl.ipc)
|
||||
wslIpc.set(wsl.ipc)
|
||||
wsl.start()
|
||||
lifecycle.setWslShutdown(wsl.stop)
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
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
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
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)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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,184 +1,55 @@
|
||||
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 { 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 { createDesktopStorage } from "./storage"
|
||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import type { UpdaterIpc } from "./updater"
|
||||
import type { WslIpc } from "./wsl/ipc"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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>
|
||||
type Deps = AppHandlerDeps & {
|
||||
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),
|
||||
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,
|
||||
)
|
||||
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,
|
||||
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])
|
||||
})
|
||||
}
|
||||
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,6 +1,7 @@
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import type { Event } from "electron"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { writeLog, type DesktopLogger } from "../native/logging"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
@@ -12,7 +13,7 @@ export function createApplicationLifecycle(logger: DesktopLogger) {
|
||||
if (!urls.length) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
|
||||
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
|
||||
}
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
type DesktopMenuEntry,
|
||||
type DesktopMenuRole,
|
||||
} from "@opencode-ai/app/desktop-menu"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { MenuCommandTriggered } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
|
||||
import { UPDATER_ENABLED } from "../constants"
|
||||
import { openExternalURL } from "../files"
|
||||
@@ -36,7 +37,7 @@ export function createMenu(deps: Deps) {
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
sendIpcEvent(win.webContents, Ipc.menu.command, id)
|
||||
emitIpcEvent(win.webContents, new MenuCommandTriggered({ id }))
|
||||
}
|
||||
|
||||
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user