mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7e9bcc2bc |
@@ -46,6 +46,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
yield* run({
|
yield* run({
|
||||||
server: {
|
server: {
|
||||||
endpoint: server.endpoint,
|
endpoint: server.endpoint,
|
||||||
|
connect: (url, signal) => runServicePromise(ServerConnection.connect(url), { signal }),
|
||||||
service: service
|
service: service
|
||||||
? {
|
? {
|
||||||
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
|
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
|
||||||
|
|||||||
@@ -21,23 +21,7 @@ export type Resolved = {
|
|||||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||||
if (args.server !== undefined && args.standalone)
|
if (args.server !== undefined && args.standalone)
|
||||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||||
if (args.server !== undefined) {
|
if (args.server !== undefined) return { endpoint: yield* connect(args.server) } satisfies Resolved
|
||||||
const password = yield* Env.password
|
|
||||||
const endpoint = {
|
|
||||||
url: args.server,
|
|
||||||
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
|
||||||
} satisfies Endpoint
|
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
|
||||||
const health = yield* Effect.tryPromise({
|
|
||||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
|
||||||
catch: (cause) => connectError(endpoint, cause),
|
|
||||||
})
|
|
||||||
if (health.version !== InstallationVersion)
|
|
||||||
process.stderr.write(
|
|
||||||
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
|
||||||
)
|
|
||||||
return { endpoint } satisfies Resolved
|
|
||||||
}
|
|
||||||
if (args.standalone) {
|
if (args.standalone) {
|
||||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||||
}
|
}
|
||||||
@@ -49,6 +33,24 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
|||||||
} satisfies Resolved
|
} satisfies Resolved
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const connect = Effect.fn("cli.server-connection.connect")(function* (url: string) {
|
||||||
|
const password = yield* Env.password
|
||||||
|
const endpoint = {
|
||||||
|
url,
|
||||||
|
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
||||||
|
} satisfies Endpoint
|
||||||
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
|
const health = yield* Effect.tryPromise({
|
||||||
|
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||||
|
catch: (cause) => connectError(endpoint, cause),
|
||||||
|
})
|
||||||
|
if (health.version !== InstallationVersion)
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
||||||
|
)
|
||||||
|
return endpoint
|
||||||
|
})
|
||||||
|
|
||||||
function managedService(options: EnsureOptions) {
|
function managedService(options: EnsureOptions) {
|
||||||
const reconnectOptions = { ...options, version: undefined }
|
const reconnectOptions = { ...options, version: undefined }
|
||||||
return {
|
return {
|
||||||
@@ -61,10 +63,7 @@ function managedService(options: EnsureOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveManaged = Effect.fnUntraced(function* (
|
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
|
||||||
options: EnsureOptions,
|
|
||||||
mismatch: NonNullable<Args["mismatch"]>,
|
|
||||||
) {
|
|
||||||
if (mismatch === "replace") return yield* Service.ensure(options)
|
if (mismatch === "replace") return yield* Service.ensure(options)
|
||||||
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
|
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
|
||||||
|
|
||||||
|
|||||||
+137
-77
@@ -81,6 +81,10 @@ import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./
|
|||||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||||
import { CommandPaletteDialog } from "./component/command-palette"
|
import { CommandPaletteDialog } from "./component/command-palette"
|
||||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||||
|
import { ServerProvider, decodeServerURLs, useServer, type ServerConnection } from "./context/server"
|
||||||
|
import { DialogServer } from "./component/dialog-server"
|
||||||
|
import { readJson, writeJsonAtomic } from "./util/persistence"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
import { DialogVariant } from "./component/dialog-variant"
|
import { DialogVariant } from "./component/dialog-variant"
|
||||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||||
@@ -94,6 +98,7 @@ registerOpencodeSpinner()
|
|||||||
const appGlobalBindingCommands = [
|
const appGlobalBindingCommands = [
|
||||||
"session.list",
|
"session.list",
|
||||||
"session.new",
|
"session.new",
|
||||||
|
"server.switch",
|
||||||
"session.quick_switch.1",
|
"session.quick_switch.1",
|
||||||
"session.quick_switch.2",
|
"session.quick_switch.2",
|
||||||
"session.quick_switch.3",
|
"session.quick_switch.3",
|
||||||
@@ -142,6 +147,7 @@ const appBindingCommands = [
|
|||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
server: {
|
server: {
|
||||||
endpoint: Endpoint
|
endpoint: Endpoint
|
||||||
|
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
|
||||||
service?: {
|
service?: {
|
||||||
reconnect: (signal: AbortSignal) => Promise<Endpoint>
|
reconnect: (signal: AbortSignal) => Promise<Endpoint>
|
||||||
restart: () => Promise<void>
|
restart: () => Promise<void>
|
||||||
@@ -182,24 +188,16 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
|
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
|
||||||
terminalSuspend: process.platform !== "win32",
|
terminalSuspend: process.platform !== "win32",
|
||||||
})
|
})
|
||||||
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
const initialAPI = OpenCode.make({
|
||||||
const api = OpenCode.make(options)
|
baseUrl: input.server.endpoint.url,
|
||||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
headers: Service.headers(input.server.endpoint),
|
||||||
Effect.map((response) => response.location.directory),
|
})
|
||||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
yield* Effect.tryPromise(() => initialAPI.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||||
|
Effect.catch(() => Effect.tryPromise(() => initialAPI.location.get())),
|
||||||
)
|
)
|
||||||
|
const serverFile = path.join(global.state, "tui-servers.json")
|
||||||
|
const serverURLs = decodeServerURLs(yield* Effect.promise(() => readJson<unknown>(serverFile).catch(() => undefined)))
|
||||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||||
const managed = input.server.service
|
|
||||||
const service = managed
|
|
||||||
? {
|
|
||||||
reconnect: async (signal: AbortSignal) => {
|
|
||||||
const endpoint = await managed.reconnect(signal)
|
|
||||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
|
||||||
return { api: OpenCode.make(next) }
|
|
||||||
},
|
|
||||||
restart: managed.restart,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
||||||
const result = yield* Effect.scoped(
|
const result = yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -315,68 +313,36 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ClipboardProvider>
|
<ClipboardProvider>
|
||||||
<ArgsProvider {...input.args}>
|
<ConfigProvider
|
||||||
<ConfigProvider
|
config={config}
|
||||||
config={config}
|
service={input.config}
|
||||||
service={input.config}
|
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
>
|
||||||
>
|
<Keymap.Provider>
|
||||||
<Keymap.Provider>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<ThemeProvider mode={mode}>
|
||||||
<RouteProvider
|
<ServerProvider
|
||||||
initialRoute={
|
initial={{ endpoint: input.server.endpoint, service: input.server.service }}
|
||||||
input.args.continue
|
urls={serverURLs}
|
||||||
? {
|
connect={input.server.connect}
|
||||||
type: "session",
|
prepare={(endpoint) => {
|
||||||
sessionID: "dummy",
|
const api = OpenCode.make({
|
||||||
}
|
baseUrl: endpoint.url,
|
||||||
: undefined
|
headers: Service.headers(endpoint),
|
||||||
}
|
})
|
||||||
|
return api.file
|
||||||
|
.list({ location: { directory: process.cwd() } })
|
||||||
|
.catch(() => api.location.get())
|
||||||
|
.then(() => undefined)
|
||||||
|
}}
|
||||||
|
save={(servers) => writeJsonAtomic(serverFile, { servers })}
|
||||||
>
|
>
|
||||||
<PluginRuntimeProvider value={pluginRuntime}>
|
<ServerScope input={input} pluginRuntime={pluginRuntime} started={appStarted} />
|
||||||
<ClientProvider api={api} service={service}>
|
</ServerProvider>
|
||||||
<PermissionProvider>
|
</ThemeProvider>
|
||||||
<DataProvider>
|
</ToastProvider>
|
||||||
<LocationProvider>
|
</Keymap.Provider>
|
||||||
<ThemeProvider mode={mode}>
|
</ConfigProvider>
|
||||||
<LocalProvider>
|
|
||||||
<PromptStashProvider>
|
|
||||||
<DialogProvider>
|
|
||||||
<FrecencyProvider>
|
|
||||||
<PromptHistoryProvider>
|
|
||||||
<PromptRefProvider>
|
|
||||||
<EditorContextProvider>
|
|
||||||
<PluginProvider packages={input.packages}>
|
|
||||||
<App
|
|
||||||
started={appStarted}
|
|
||||||
pair={
|
|
||||||
input.server.endpoint.auth
|
|
||||||
? input.server.endpoint.auth
|
|
||||||
: {
|
|
||||||
username: "opencode",
|
|
||||||
password: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</PluginProvider>
|
|
||||||
</EditorContextProvider>
|
|
||||||
</PromptRefProvider>
|
|
||||||
</PromptHistoryProvider>
|
|
||||||
</FrecencyProvider>
|
|
||||||
</DialogProvider>
|
|
||||||
</PromptStashProvider>
|
|
||||||
</LocalProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</LocationProvider>
|
|
||||||
</DataProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
</ClientProvider>
|
|
||||||
</PluginRuntimeProvider>
|
|
||||||
</RouteProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
</Keymap.Provider>
|
|
||||||
</ConfigProvider>
|
|
||||||
</ArgsProvider>
|
|
||||||
</ClipboardProvider>
|
</ClipboardProvider>
|
||||||
</TuiStartupProvider>
|
</TuiStartupProvider>
|
||||||
</TuiTerminalEnvironmentProvider>
|
</TuiTerminalEnvironmentProvider>
|
||||||
@@ -405,6 +371,91 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type ServerScopeProps = {
|
||||||
|
input: TuiInput
|
||||||
|
pluginRuntime: ReturnType<typeof createPluginRuntime>
|
||||||
|
started: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerScope(props: ServerScopeProps) {
|
||||||
|
const server = useServer()
|
||||||
|
let startup = true
|
||||||
|
return (
|
||||||
|
<Show when={server.current} keyed>
|
||||||
|
{(connection) => {
|
||||||
|
const initial = startup
|
||||||
|
startup = false
|
||||||
|
return <ServerApp {...props} connection={connection} startup={initial} />
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerApp(props: ServerScopeProps & { connection: ServerConnection; startup: boolean }) {
|
||||||
|
const api = OpenCode.make({
|
||||||
|
baseUrl: props.connection.endpoint.url,
|
||||||
|
headers: Service.headers(props.connection.endpoint),
|
||||||
|
})
|
||||||
|
const managed = props.connection.service
|
||||||
|
const service = managed
|
||||||
|
? {
|
||||||
|
reconnect: async (signal: AbortSignal) => {
|
||||||
|
const endpoint = await managed.reconnect(signal)
|
||||||
|
return {
|
||||||
|
api: OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restart: managed.restart,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
const args = props.startup ? props.input.args : {}
|
||||||
|
return (
|
||||||
|
<ArgsProvider {...args}>
|
||||||
|
<RouteProvider
|
||||||
|
initialRoute={
|
||||||
|
args.continue ? { type: "session", sessionID: "dummy" } : props.startup ? undefined : { type: "home" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PluginRuntimeProvider value={props.pluginRuntime}>
|
||||||
|
<ClientProvider api={api} service={service}>
|
||||||
|
<PermissionProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<LocationProvider>
|
||||||
|
<LocalProvider>
|
||||||
|
<PromptStashProvider>
|
||||||
|
<DialogProvider>
|
||||||
|
<FrecencyProvider>
|
||||||
|
<PromptHistoryProvider>
|
||||||
|
<PromptRefProvider>
|
||||||
|
<EditorContextProvider>
|
||||||
|
<PluginProvider packages={props.input.packages}>
|
||||||
|
<App
|
||||||
|
started={props.started}
|
||||||
|
pair={
|
||||||
|
props.connection.endpoint.auth ?? {
|
||||||
|
username: "opencode",
|
||||||
|
password: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PluginProvider>
|
||||||
|
</EditorContextProvider>
|
||||||
|
</PromptRefProvider>
|
||||||
|
</PromptHistoryProvider>
|
||||||
|
</FrecencyProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</PromptStashProvider>
|
||||||
|
</LocalProvider>
|
||||||
|
</LocationProvider>
|
||||||
|
</DataProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</RouteProvider>
|
||||||
|
</ArgsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App(props: { pair?: DialogPairCredentials; started: number }) {
|
function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||||
const log = useLog({ component: "app" })
|
const log = useLog({ component: "app" })
|
||||||
const startup = useTuiStartup()
|
const startup = useTuiStartup()
|
||||||
@@ -766,6 +817,15 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
},
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "server.switch",
|
||||||
|
title: "Switch server",
|
||||||
|
slash: { name: "servers" },
|
||||||
|
run: () => {
|
||||||
|
dialog.replace(() => <DialogServer />)
|
||||||
|
},
|
||||||
|
category: "System",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "server.pair",
|
name: "server.pair",
|
||||||
title: "Pair device",
|
title: "Pair device",
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { useServer } from "../context/server"
|
||||||
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
|
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||||
|
import { useDialog } from "../ui/dialog"
|
||||||
|
import { useToast } from "../ui/toast"
|
||||||
|
|
||||||
|
export function DialogServer() {
|
||||||
|
const server = useServer()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const toast = useToast()
|
||||||
|
const options = createMemo(() =>
|
||||||
|
server.list().map((item) => ({
|
||||||
|
title: item.name,
|
||||||
|
description: item.url,
|
||||||
|
value: item.id,
|
||||||
|
onSelect: () => {
|
||||||
|
dialog.clear()
|
||||||
|
void server
|
||||||
|
.select(item.id)
|
||||||
|
.then(() => toast.show({ variant: "success", message: `Switched to ${item.name}` }), toast.error)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
void DialogPrompt.show(dialog, "Add server", {
|
||||||
|
placeholder: "https://devbox.example",
|
||||||
|
description: () => <text>Enter the URL of an OpenCode V2 server.</text>,
|
||||||
|
}).then((value) => {
|
||||||
|
if (!value) return
|
||||||
|
dialog.clear()
|
||||||
|
void server
|
||||||
|
.add(value)
|
||||||
|
.then(() => toast.show({ variant: "success", message: `Connected to ${server.current.name}` }), toast.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogSelect
|
||||||
|
title="Switch server"
|
||||||
|
options={options()}
|
||||||
|
current={server.current.id}
|
||||||
|
actions={[{ command: "server.add", title: "Add server", selection: "none", onTrigger: add }]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ export const Definitions = {
|
|||||||
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
|
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
|
||||||
status_view: keybind("<leader>s", "View status"),
|
status_view: keybind("<leader>s", "View status"),
|
||||||
debug_view: keybind("none", "View debug info"),
|
debug_view: keybind("none", "View debug info"),
|
||||||
|
server_switch: keybind("<leader>w", "Switch server"),
|
||||||
|
server_add: keybind("ctrl+a", "Add server"),
|
||||||
|
|
||||||
session_export: keybind("<leader>x", "Export session to editor"),
|
session_export: keybind("<leader>x", "Export session to editor"),
|
||||||
session_copy: keybind("none", "Copy session transcript"),
|
session_copy: keybind("none", "Copy session transcript"),
|
||||||
@@ -283,6 +285,8 @@ export const CommandMap = {
|
|||||||
scrollbar_toggle: "session.toggle.scrollbar",
|
scrollbar_toggle: "session.toggle.scrollbar",
|
||||||
status_view: "opencode.status",
|
status_view: "opencode.status",
|
||||||
debug_view: "opencode.debug",
|
debug_view: "opencode.debug",
|
||||||
|
server_switch: "server.switch",
|
||||||
|
server_add: "server.add",
|
||||||
session_export: "session.export",
|
session_export: "session.export",
|
||||||
session_copy: "session.copy",
|
session_copy: "session.copy",
|
||||||
session_move: "session.move",
|
session_move: "session.move",
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
|
import { createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
|
||||||
|
|
||||||
|
export type ServerConnection = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
endpoint: Endpoint
|
||||||
|
service?: {
|
||||||
|
reconnect: (signal: AbortSignal) => Promise<Endpoint>
|
||||||
|
restart: () => Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerInfo = Pick<ServerConnection, "id" | "name" | "url">
|
||||||
|
|
||||||
|
type ServerContext = {
|
||||||
|
readonly current: ServerConnection
|
||||||
|
list: () => ServerInfo[]
|
||||||
|
select: (id: string) => Promise<void>
|
||||||
|
add: (url: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = createContext<ServerContext>()
|
||||||
|
|
||||||
|
export function ServerProvider(
|
||||||
|
props: ParentProps<{
|
||||||
|
initial: Omit<ServerConnection, "id" | "name" | "url">
|
||||||
|
urls: string[]
|
||||||
|
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
|
||||||
|
prepare: (endpoint: Endpoint) => Promise<void>
|
||||||
|
save: (urls: string[]) => Promise<void>
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const initialURL = normalizeServerURL(props.initial.endpoint.url)
|
||||||
|
const initial = {
|
||||||
|
...props.initial,
|
||||||
|
id: initialURL,
|
||||||
|
name: serverName(initialURL),
|
||||||
|
url: initialURL,
|
||||||
|
}
|
||||||
|
const [current, setCurrent] = createSignal(initial)
|
||||||
|
const [urls, setURLs] = createSignal(
|
||||||
|
props.urls.map(normalizeServerURL).filter((url, index, all) => url !== initialURL && all.indexOf(url) === index),
|
||||||
|
)
|
||||||
|
const list = createMemo(() => [
|
||||||
|
{ id: initial.id, name: initial.name, url: initial.url },
|
||||||
|
...urls().map((url) => ({ id: url, name: serverName(url), url })),
|
||||||
|
])
|
||||||
|
|
||||||
|
async function select(id: string) {
|
||||||
|
if (id === current().id) return
|
||||||
|
if (id === initial.id) {
|
||||||
|
setCurrent(initial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const info = list().find((item) => item.id === id)
|
||||||
|
if (!info) throw new Error(`Unknown server: ${id}`)
|
||||||
|
const endpoint = await props.connect(info.url)
|
||||||
|
await props.prepare(endpoint)
|
||||||
|
setCurrent({ ...info, endpoint })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(value: string) {
|
||||||
|
const url = normalizeServerURL(value)
|
||||||
|
const existing = list().find((item) => item.url === url)
|
||||||
|
if (existing) return select(existing.id)
|
||||||
|
const endpoint = await props.connect(url)
|
||||||
|
await props.prepare(endpoint)
|
||||||
|
const next = [...urls(), url]
|
||||||
|
await props.save(next)
|
||||||
|
setURLs(next)
|
||||||
|
setCurrent({ id: url, name: serverName(url), url, endpoint })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<context.Provider
|
||||||
|
value={{
|
||||||
|
get current() {
|
||||||
|
return current()
|
||||||
|
},
|
||||||
|
list,
|
||||||
|
select,
|
||||||
|
add,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServer() {
|
||||||
|
const value = useContext(context)
|
||||||
|
if (!value) throw new Error("Server context must be used within a ServerProvider")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeServerURLs(input: unknown) {
|
||||||
|
if (!input || typeof input !== "object" || !("servers" in input) || !Array.isArray(input.servers)) return []
|
||||||
|
return input.servers.flatMap((item) => {
|
||||||
|
if (typeof item !== "string") return []
|
||||||
|
try {
|
||||||
|
return [normalizeServerURL(item)]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServerURL(value: string) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
const input = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
|
||||||
|
if (!URL.canParse(input)) throw new Error(`Invalid server URL: ${trimmed || value}`)
|
||||||
|
const url = new URL(input)
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Server URL must use HTTP or HTTPS")
|
||||||
|
if (url.username || url.password) throw new Error("Server URL must not contain credentials")
|
||||||
|
if (url.search || url.hash) throw new Error("Server URL must not contain a query or fragment")
|
||||||
|
url.pathname = url.pathname.replace(/\/+$/, "") || "/"
|
||||||
|
return url.href.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverName(value: string) {
|
||||||
|
const url = new URL(value)
|
||||||
|
if (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return "Local"
|
||||||
|
return url.host
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
|||||||
const { run } = await import("../src/app")
|
const { run } = await import("../src/app")
|
||||||
const task = Effect.runPromise(
|
const task = Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) },
|
||||||
config: { get: async () => ({}), update: async () => ({}) },
|
config: { get: async () => ({}), update: async () => ({}) },
|
||||||
packages: { resolve: async () => undefined },
|
packages: { resolve: async () => undefined },
|
||||||
args: {},
|
args: {},
|
||||||
@@ -100,7 +100,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
|||||||
const { run } = await import("../src/app")
|
const { run } = await import("../src/app")
|
||||||
const task = Effect.runPromise(
|
const task = Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) },
|
||||||
config: { get: async () => ({}), update: async () => ({}) },
|
config: { get: async () => ({}), update: async () => ({}) },
|
||||||
packages: { resolve: async () => undefined },
|
packages: { resolve: async () => undefined },
|
||||||
args: { sessionID: "dummy" },
|
args: { sessionID: "dummy" },
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ServerProvider, decodeServerURLs, normalizeServerURL, serverName, useServer } from "../../src/context/server"
|
||||||
|
|
||||||
|
describe("TUI servers", () => {
|
||||||
|
test("normalizes equivalent endpoint URLs", () => {
|
||||||
|
expect(normalizeServerURL(" https://devbox.example/ ")).toBe("https://devbox.example")
|
||||||
|
expect(normalizeServerURL("http://localhost:4096///")).toBe("http://localhost:4096")
|
||||||
|
expect(normalizeServerURL("devbox.example:4096")).toBe("http://devbox.example:4096")
|
||||||
|
expect(() => normalizeServerURL("https://user:secret@devbox.example")).toThrow("must not contain credentials")
|
||||||
|
expect(() => normalizeServerURL("https://devbox.example?token=secret")).toThrow("query or fragment")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("labels loopback and remote servers", () => {
|
||||||
|
expect(serverName("http://127.0.0.1:49374")).toBe("Local")
|
||||||
|
expect(serverName("https://devbox.example:4096")).toBe("devbox.example:4096")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("decodes only valid persisted URLs", () => {
|
||||||
|
expect(decodeServerURLs({ servers: ["https://devbox.example", 1, "ftp://nope"] })).toEqual([
|
||||||
|
"https://devbox.example",
|
||||||
|
])
|
||||||
|
expect(decodeServerURLs(null)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("switches only after the next server is prepared", async () => {
|
||||||
|
let server!: ReturnType<typeof useServer>
|
||||||
|
const steps: string[] = []
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
server = useServer()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<ServerProvider
|
||||||
|
initial={{ endpoint: { url: "http://localhost:4096" } }}
|
||||||
|
urls={["https://devbox.example", "http://localhost:4096/"]}
|
||||||
|
connect={async (url) => {
|
||||||
|
steps.push(`connect:${url}`)
|
||||||
|
return { url }
|
||||||
|
}}
|
||||||
|
prepare={async (endpoint) => {
|
||||||
|
steps.push(`prepare:${endpoint.url}`)
|
||||||
|
}}
|
||||||
|
save={async () => {}}
|
||||||
|
>
|
||||||
|
<Harness />
|
||||||
|
</ServerProvider>
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
expect(server.list().map((item) => item.url)).toEqual(["http://localhost:4096", "https://devbox.example"])
|
||||||
|
await server.select("https://devbox.example")
|
||||||
|
expect(steps).toEqual(["connect:https://devbox.example", "prepare:https://devbox.example"])
|
||||||
|
expect(server.current.url).toBe("https://devbox.example")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("persists only the normalized URL after a successful add", async () => {
|
||||||
|
let server!: ReturnType<typeof useServer>
|
||||||
|
const writes: string[][] = []
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
server = useServer()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<ServerProvider
|
||||||
|
initial={{ endpoint: { url: "http://localhost:4096" } }}
|
||||||
|
urls={[]}
|
||||||
|
connect={async (url) => ({
|
||||||
|
url,
|
||||||
|
auth: { type: "basic", username: "opencode", password: "secret" },
|
||||||
|
})}
|
||||||
|
prepare={async () => {}}
|
||||||
|
save={async (urls) => void writes.push(urls)}
|
||||||
|
>
|
||||||
|
<Harness />
|
||||||
|
</ServerProvider>
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
await server.add("devbox.example:4096/")
|
||||||
|
expect(writes).toEqual([["http://devbox.example:4096"]])
|
||||||
|
expect(JSON.stringify(writes)).not.toContain("secret")
|
||||||
|
expect(server.current.endpoint.auth?.password).toBe("secret")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the current server when preparing a new endpoint fails", async () => {
|
||||||
|
let server!: ReturnType<typeof useServer>
|
||||||
|
let saved = false
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
server = useServer()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<ServerProvider
|
||||||
|
initial={{ endpoint: { url: "http://localhost:4096" } }}
|
||||||
|
urls={[]}
|
||||||
|
connect={async (url) => ({ url })}
|
||||||
|
prepare={async () => {
|
||||||
|
throw new Error("unreachable")
|
||||||
|
}}
|
||||||
|
save={async () => {
|
||||||
|
saved = true
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Harness />
|
||||||
|
</ServerProvider>
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
await expect(server.add("https://devbox.example")).rejects.toThrow("unreachable")
|
||||||
|
expect(server.current.url).toBe("http://localhost:4096")
|
||||||
|
expect(saved).toBeFalse()
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -115,3 +115,34 @@ test("global commands stay reachable when the mode changes", async () => {
|
|||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("server switch has a default global shortcut", async () => {
|
||||||
|
let shortcut = () => ""
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
commands: [{ id: "server.switch", run() {} }],
|
||||||
|
}))
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
bindings: ["server.switch"],
|
||||||
|
}))
|
||||||
|
shortcut = () => shortcuts.get("server.switch") ?? ""
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<Harness />
|
||||||
|
</Keymap.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
expect(shortcut()).toBe("ctrl+x w")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user