Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/app/src/home/projects/reveal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { displayName } from "@opencode/ui/project-avatar"
import { errorMessage } from "@/shell/layout/helpers"
import type { LocalProject } from "@/shell/state/layout"
Expand All @@ -9,9 +9,10 @@ import { showToast } from "@/shell/notifications/toast"
export function useRevealProject() {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()

const available = (conn: ServerConnection.Any) =>
platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn)
platform.platform === "desktop" && !!platform.revealPath && servers.local(conn)

return {
available,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/runtime/plugin/host-apis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ export function createPluginAttachment(apis: HostApis) {
return live().data
},
get local() {
return ServerConnection.local(listed())
return servers.local(listed())
},
get builtin() {
return ServerConnection.builtin(listed())
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/runtime/server/current.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const context = createSimpleContext({
return {
conn,
key,
isLocal: ServerConnection.local(props.conn),
get isLocal() {
return props.ctx.isLocal
},
ctx: props.ctx,
get health() {
return global.servers.health[key]
Expand Down
42 changes: 27 additions & 15 deletions packages/app/src/runtime/server/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,24 +52,36 @@ test("startup auth_token credentials override a persisted same-url server; witho
expect(kept[0]?.type === "http" ? kept[0].authToken : true).toBeUndefined()
})

test("treats WSL sidecars as remote server connections", () => {
test("treats WSL and mismatched-port loopback servers as remote connections", () => {
const windowsSidecar = "http://127.0.0.1:28894"

expect(
ServerConnection.local({
type: "plugin",
key: "wsl:Debian",
plugin: "wsl",
state: "ready",
connecting: false,
authenticationRequired: false,
managed: false,
http: { url: "http://127.0.0.1:4097" },
}),
ServerConnection.local(
{
type: "plugin",
key: "wsl:Debian",
plugin: "wsl",
state: "ready",
connecting: false,
authenticationRequired: false,
managed: false,
http: { url: "http://127.0.0.1:4097" },
},
windowsSidecar,
),
).toBe(false)
expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe(
true,
expect(
ServerConnection.local({ type: "sidecar", variant: "base", http: { url: windowsSidecar } }),
).toBe(true)
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:28894" } }, windowsSidecar)).toBe(true)
expect(ServerConnection.local({ type: "http", http: { url: "http://[::1]:28894" } }, windowsSidecar)).toBe(true)
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } }, windowsSidecar)).toBe(false)
expect(ServerConnection.local({ type: "http", http: { url: "http://127.0.0.1:4096" } }, windowsSidecar)).toBe(false)
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:49374" } }, windowsSidecar)).toBe(false)
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(false)
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } }, windowsSidecar)).toBe(
false,
)
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true)
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
})

test("keeps exact persisted server identities and prevents removing provided servers", () => {
Expand Down
28 changes: 23 additions & 5 deletions packages/app/src/runtime/server/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,13 @@ export function serverLabel(conn: ServerConnection.Any, current?: { hostname?: s
return serverName(conn)
}

function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
function loopbackOrigin(url: string) {
if (!URL.canParse(url)) return
const parsed = new URL(url)

if (host === "localhost" || host === "127.0.0.1") return "local"
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "[::1]") return

return `${parsed.protocol}//127.0.0.1:${parsed.port || (parsed.protocol === "https:" ? "443" : "80")}`
}

// Project data comes from the server; this keeps which projects this client has used and the last one.
Expand Down Expand Up @@ -192,8 +195,18 @@ export namespace ServerConnection {
return true
}

export const local = (conn?: Any) =>
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
// The desktop sidecar, or a loopback http connection on the same port as the app's own server. Other loopback
// ports may belong to a WSL server, tunnel, or container with a separate filesystem.
export const local = (conn?: Any, ownUrl?: string) => {
if (!conn) return false

if (builtin(conn)) return true

if (conn.type !== "http" || !ownUrl) return false
const origin = loopbackOrigin(conn.http.url)

return !!origin && origin === loopbackOrigin(ownUrl)
}
}

export const { use: useServers, provider: ServersProvider } = createSimpleContext({
Expand Down Expand Up @@ -265,6 +278,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
const current = (conn: ServerConnection.Any) =>
ServerConnection.builtin(conn) || ServerConnection.key(conn) === props.canonicalLocalServer

const ownUrl = () => props.servers?.find(ServerConnection.builtin)?.http.url ?? props.canonicalLocalServer
const local = (conn?: ServerConnection.Any) => ServerConnection.local(conn, ownUrl())

const label = (conn: ServerConnection.Any) =>
serverLabel(
conn,
Expand Down Expand Up @@ -294,6 +310,8 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
canRemove,
scope,
current,
ownUrl,
local,
/** The name switchers, menus and titles show. */
label,
/** The name a list row shows next to its item: nothing for the current server or when it is the only one. */
Expand Down
22 changes: 11 additions & 11 deletions packages/app/src/runtime/server/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)

return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
return createServerController(
conn,
server.scope(key),
() => server.local(conn),
server.projects.forServer(key),
notificationCoordinator,
)
}, owner)

serverCtxs.set(key, serverCtx)
Expand Down Expand Up @@ -185,6 +191,7 @@ function createGlobalModels() {
function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
isLocal: Accessor<boolean>,
projects: ReturnType<typeof createServerProjects>,
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
) {
Expand Down Expand Up @@ -321,14 +328,13 @@ function createServerController(
})
}

const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))

return {
data,
sdk,
sync,
isLocal,
get isLocal() {
return isLocal()
},
projects: {
list,
unique,
Expand Down Expand Up @@ -367,12 +373,6 @@ export function useServerCtx(server: Accessor<ServerConnection.Any | undefined>)

export type ServerCtx = ReturnType<typeof createServerController>

function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]

if (host === "localhost" || host === "127.0.0.1") return "local"
}

/** What an HTTP server's controller authenticates with; other servers keep one controller per id. */
function credentials(conn: ServerConnection.Any) {
return conn.type === "http" ? `${conn.http.url}\n${conn.http.password ?? ""}` : ""
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/settings/surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { ServerConnection } from "@/runtime/server/registry"
import { useServers } from "@/runtime/server/registry"
import { useCommand } from "@/shell/commands/command"
import { createSettingsPages } from "@/runtime/plugin/settings-pages"
import { useSettingsServers } from "./servers/inventory"
Expand Down Expand Up @@ -38,6 +38,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
const layout = useLayout()
const command = useCommand()
const servers = useSettingsServers()
const registry = useServers()
const tabs = useTabs()

const location = useLocation<{
Expand Down Expand Up @@ -76,7 +77,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr

return (
list.find((item) => item.key === key) ??
list.find((item) => !!item.connection && ServerConnection.local(item.connection)) ??
list.find((item) => !!item.connection && registry.local(item.connection)) ??
list[0]
)
})
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/workspaces/selection/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
import { inferHomeDirectory } from "@/runtime/server/global-sync/bootstrap"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { pathKey } from "@/workspaces/path-key"
import type { LocationRef } from "@opencode/client/promise"
import {
Expand Down Expand Up @@ -61,6 +61,7 @@ interface DirectoryPickerDialogProps {

export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
const global = useGlobal()
const servers = useServers()
const serverCtx = global.ensureServerCtx(props.server)
const sync = serverCtx.sync
const sdk = serverCtx.sdk
Expand Down Expand Up @@ -121,7 +122,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
sync.data.path.home || inferHomeDirectory(props.location?.directory ?? ""),
)

const subtitle = createMemo(() => (ServerConnection.local(props.server) ? undefined : serverName(props.server)))
const subtitle = createMemo(() => (servers.local(props.server) ? undefined : serverName(props.server)))

const location = createMemo(() => {
const current = props.location ?? fallbackPath.latest
Expand Down
23 changes: 16 additions & 7 deletions packages/app/src/workspaces/selection/picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { directoryPickerKind } from "./policy"
const local = {
type: "sidecar",
variant: "base",
http: { url: "http://localhost:4096" },
http: { url: "http://127.0.0.1:28894" },
} as const

const remote = {
Expand All @@ -20,11 +20,20 @@ const remote = {

describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("desktop", { ...remote, key: "wsl:Ubuntu", plugin: "wsl", managed: false })).toBe(
"server",
)
expect(directoryPickerKind("web", local)).toBe("server")
expect(directoryPickerKind("desktop", local, local.http.url)).toBe("native")
expect(
directoryPickerKind("desktop", { type: "http", http: { url: "http://localhost:28894" } }, local.http.url),
).toBe("native")
expect(
directoryPickerKind("desktop", { type: "http", http: { url: "http://localhost:4096" } }, local.http.url),
).toBe("server")
expect(
directoryPickerKind("desktop", { type: "http", http: { url: "http://localhost:49374" } }, local.http.url),
).toBe("server")
expect(directoryPickerKind("desktop", remote, local.http.url)).toBe("server")
expect(
directoryPickerKind("desktop", { ...remote, key: "wsl:Ubuntu", plugin: "wsl", managed: false }, local.http.url),
).toBe("server")
expect(directoryPickerKind("web", local, local.http.url)).toBe("server")
})
})
8 changes: 6 additions & 2 deletions packages/app/src/workspaces/selection/picker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { lazy, Suspense } from "solid-js"
import type { LocationRef } from "@opencode/client/promise"
Expand All @@ -19,10 +19,14 @@ type DirectoryPickerInput = {

export function useDirectoryPicker() {
const platform = usePlatform()
const servers = useServers()
const dialog = useDialog()

return (input: DirectoryPickerInput) => {
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
if (
directoryPickerKind(platform.platform, input.server, servers.ownUrl()) === "native" &&
platform.platform === "desktop"
) {
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)

return
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/workspaces/selection/policy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ServerConnection } from "@/runtime/server/registry"
import type { Platform } from "@/runtime/platform/platform"

export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any, ownUrl?: string) {
if (platform === "desktop" && ServerConnection.local(server, ownUrl)) return "native" as const

return "server" as const
}
2 changes: 1 addition & 1 deletion packages/plugin/src/gui/host-apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface ServerRef {
readonly client: OpenCodeClient
/** The server's synced data (sessions, projects, …). Reactive. */
readonly data: Data
/** The built-in local server or a loopback http server. */
/** The app's own local server, or a loopback http connection to its port. */
readonly local: boolean
/** The app's own server, the desktop sidecar. */
readonly builtin: boolean
Expand Down
Loading