## Summary - add SideCar support across the server and UI, including proxied tabs, picker/settings flows, and websocket-aware proxying - unify top-level tab handling so workspace instances and SideCars share the same tab model and navigation flows - limit SideCars to port-based services only, removing server-managed process control from the final API and UI --------- Co-authored-by: Shantur <shantur@Mac.home> Co-authored-by: Shantur <shantur@Shanturs-MacBook-Pro-M5.local>
150 lines
4.0 KiB
TypeScript
150 lines
4.0 KiB
TypeScript
import { createMemo, createSignal } from "solid-js"
|
|
import { serverApi } from "../lib/api-client"
|
|
import { tGlobal } from "../lib/i18n"
|
|
import { serverEvents } from "../lib/server-events"
|
|
import { getLogger } from "../lib/logger"
|
|
import type { SideCar } from "../../../server/src/api-types"
|
|
|
|
const log = getLogger("api")
|
|
|
|
export interface SideCarTabRecord {
|
|
token: string
|
|
sidecarId: string
|
|
name: string
|
|
port?: number
|
|
prefixMode: SideCar["prefixMode"]
|
|
proxyBasePath: string
|
|
shellUrl: string
|
|
}
|
|
|
|
function buildSidecarShellUrl(sidecarId: string): string {
|
|
return `/sidecars/${encodeURIComponent(sidecarId)}/`
|
|
}
|
|
|
|
const [sidecars, setSidecars] = createSignal<Map<string, SideCar>>(new Map())
|
|
const [sidecarTabs, setSidecarTabs] = createSignal<SideCarTabRecord[]>([])
|
|
const [activeSidecarToken, setActiveSidecarToken] = createSignal<string | null>(null)
|
|
const [sidecarsLoading, setSidecarsLoading] = createSignal(false)
|
|
|
|
let loadPromise: Promise<void> | null = null
|
|
|
|
async function ensureSidecarsLoaded() {
|
|
if (loadPromise) return loadPromise
|
|
setSidecarsLoading(true)
|
|
loadPromise = serverApi.fetchSidecars()
|
|
.then((result) => {
|
|
setSidecars(new Map(result.sidecars.map((sidecar) => [sidecar.id, sidecar])))
|
|
})
|
|
.catch((error) => {
|
|
log.error("Failed to load SideCars", error)
|
|
})
|
|
.finally(() => {
|
|
setSidecarsLoading(false)
|
|
loadPromise = null
|
|
})
|
|
return loadPromise
|
|
}
|
|
|
|
function upsertSidecar(sidecar: SideCar) {
|
|
setSidecars((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(sidecar.id, sidecar)
|
|
return next
|
|
})
|
|
|
|
setSidecarTabs((prev) =>
|
|
prev.map((tab) =>
|
|
tab.sidecarId === sidecar.id
|
|
? {
|
|
...tab,
|
|
name: sidecar.name,
|
|
port: sidecar.port,
|
|
prefixMode: sidecar.prefixMode,
|
|
proxyBasePath: buildSidecarShellUrl(sidecar.id).replace(/\/$/, ""),
|
|
shellUrl: buildSidecarShellUrl(sidecar.id),
|
|
}
|
|
: tab,
|
|
),
|
|
)
|
|
}
|
|
|
|
function removeSidecar(sidecarId: string) {
|
|
setSidecars((prev) => {
|
|
const next = new Map(prev)
|
|
next.delete(sidecarId)
|
|
return next
|
|
})
|
|
|
|
setSidecarTabs((prev) => {
|
|
const next = prev.filter((tab) => tab.sidecarId !== sidecarId)
|
|
if (!next.some((tab) => tab.token === activeSidecarToken())) {
|
|
setActiveSidecarToken(next[0]?.token ?? null)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
serverEvents.on("sidecar.updated", (event) => {
|
|
if (event.type !== "sidecar.updated") return
|
|
upsertSidecar(event.sidecar)
|
|
})
|
|
|
|
serverEvents.on("sidecar.removed", (event) => {
|
|
if (event.type !== "sidecar.removed") return
|
|
removeSidecar(event.sidecarId)
|
|
})
|
|
|
|
async function openSidecarTab(sidecarId: string) {
|
|
await ensureSidecarsLoaded()
|
|
|
|
const sidecar = sidecars().get(sidecarId)
|
|
if (!sidecar) {
|
|
throw new Error(tGlobal("sidecars.open.notFound"))
|
|
}
|
|
if (sidecar.status !== "running") {
|
|
throw new Error(tGlobal("sidecars.open.notRunning"))
|
|
}
|
|
|
|
const token = `${sidecarId}:${Date.now().toString(36)}`
|
|
const nextTab: SideCarTabRecord = {
|
|
token,
|
|
sidecarId,
|
|
name: sidecar.name,
|
|
port: sidecar.port,
|
|
prefixMode: sidecar.prefixMode,
|
|
proxyBasePath: buildSidecarShellUrl(sidecarId).replace(/\/$/, ""),
|
|
shellUrl: buildSidecarShellUrl(sidecarId),
|
|
}
|
|
|
|
setSidecarTabs((prev) => [...prev, nextTab])
|
|
setActiveSidecarToken(nextTab.token)
|
|
return nextTab
|
|
}
|
|
|
|
function closeSidecarTab(token: string) {
|
|
setSidecarTabs((prev) => {
|
|
const index = prev.findIndex((tab) => tab.token === token)
|
|
if (index < 0) return prev
|
|
const next = prev.filter((tab) => tab.token !== token)
|
|
if (activeSidecarToken() === token) {
|
|
const fallback = next[index - 1] ?? next[index] ?? null
|
|
setActiveSidecarToken(fallback?.token ?? null)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const activeSidecarTab = createMemo(() => sidecarTabs().find((tab) => tab.token === activeSidecarToken()) ?? null)
|
|
|
|
export {
|
|
sidecars,
|
|
sidecarTabs,
|
|
activeSidecarToken,
|
|
activeSidecarTab,
|
|
sidecarsLoading,
|
|
setActiveSidecarToken,
|
|
ensureSidecarsLoaded,
|
|
openSidecarTab,
|
|
closeSidecarTab,
|
|
}
|