mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 17:36:27 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb3258c8b | ||
|
|
b6c474d155 | ||
|
|
73e5009a25 | ||
|
|
04d5cf406a | ||
|
|
f5efd33f4e |
@@ -86,6 +86,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
@@ -103,6 +104,7 @@
|
||||
"diff": "catalog:",
|
||||
"happy-dom": "20.11.1",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"uqr": "0.1.3",
|
||||
"vite": "8.2.2",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-solid": "2.11.14",
|
||||
@@ -3206,6 +3208,8 @@
|
||||
|
||||
"@types/npmlog": ["@types/npmlog@7.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ=="],
|
||||
|
||||
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
|
||||
|
||||
"@types/pacote": ["@types/pacote@11.1.8", "", { "dependencies": { "@types/node": "*", "@types/npm-registry-fetch": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
@@ -5138,6 +5142,8 @@
|
||||
|
||||
"pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
|
||||
|
||||
"qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="],
|
||||
|
||||
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createWebApp(domain: string) {
|
||||
$app.stage === "beta"
|
||||
? {
|
||||
OPENCODE_CHANNEL: "beta",
|
||||
VITE_OPENCODE_SERVER_MODE: "none",
|
||||
VITE_SENTRY_ENVIRONMENT: "beta",
|
||||
}
|
||||
: undefined,
|
||||
|
||||
+22
-2
@@ -83,8 +83,28 @@ Changes merged into `v2` reach the beta site when they are promoted to `beta`. T
|
||||
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
|
||||
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
|
||||
|
||||
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
|
||||
override this default. Connecting still requires the service's credentials.
|
||||
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
|
||||
|
||||
| Mode | Initial server |
|
||||
| ------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `none` | No initial server. The beta deployment uses this mode. |
|
||||
| `origin` (default) | The current page's origin. CLI builds explicitly use this mode for `opencode serve`. |
|
||||
|
||||
In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPENCODE_SERVER_PORT`
|
||||
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
|
||||
from storage. Desktop provides the local server it discovers or starts through native initialization.
|
||||
|
||||
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
|
||||
or choose **Scan QR code** to open the camera and read the JSON pairing code from `opencode pair`.
|
||||
Scanning fills the form; **Connect** checks the credentials before saving the server. Camera access requires
|
||||
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
|
||||
|
||||
Run `bun run test:server-connect` to build in `none` mode and test onboarding, password authentication,
|
||||
persistence, and QR scanning through Chromium's virtual camera. To use an existing `none`-mode app build:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4446 bun run test:server-connect
|
||||
```
|
||||
|
||||
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
|
||||
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test.use({ launchOptions: { args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"] } })
|
||||
|
||||
test("stops the camera on cancel", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Scan QR code" }).click()
|
||||
const video = page.getByLabel("Pairing camera")
|
||||
await expect(video).toHaveJSProperty("readyState", 4)
|
||||
await expect(video).toBeVisible()
|
||||
await expect(video).toHaveCSS("opacity", "1")
|
||||
const stream = await video.evaluateHandle((element: HTMLVideoElement) => element.srcObject as MediaStream)
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(page.getByLabel("Password", { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => stream.evaluate((value) => value.getTracks().every((track) => track.readyState === "ended")))
|
||||
.toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
const password = "pairing-test-password"
|
||||
|
||||
test("checks the password before saving and restores the connection after reload", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
provider: { all: [], default: {}, connected: [] },
|
||||
directory: "/fixture",
|
||||
project: { id: "fixture", worktree: "/fixture", time: { created: 1 } },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(`${server}/api/health`, (route) =>
|
||||
route.fulfill({
|
||||
status:
|
||||
route.request().headers().authorization === `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
? 200
|
||||
: 401,
|
||||
json: { healthy: true, version: "2.0.0" },
|
||||
}),
|
||||
)
|
||||
await page.goto("/")
|
||||
await expect(page.getByRole("main", { name: "Connect to a server" })).toBeVisible()
|
||||
await page.getByLabel("Server address").fill(server)
|
||||
await page.getByLabel("Password", { exact: true }).fill("wrong-password")
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click()
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"Could not connect. Check the server address and password, then try again.",
|
||||
)
|
||||
expect(await page.evaluate(() => localStorage.getItem("opencode.global.dat:server"))).toBeNull()
|
||||
|
||||
await page.getByLabel("Password", { exact: true }).fill(password)
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("opencode.global.dat:server"))).toContain(password)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a saved offline server does not trigger first-server onboarding", async ({ page }) => {
|
||||
await page.addInitScript((server) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", http: { url: server } }] }),
|
||||
)
|
||||
}, server)
|
||||
await page.route(`${server}/**`, (route) => route.abort())
|
||||
await page.goto("/")
|
||||
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("camera failure allows returning to the manual form", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto("/")
|
||||
await page.getByLabel("Server address").fill(server)
|
||||
await page.getByRole("button", { name: "Scan QR code" }).click()
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"Could not open the camera. Allow camera access or enter your connection details manually.",
|
||||
)
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(page.getByLabel("Server address")).toHaveValue(server)
|
||||
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled()
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 4456)
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.spec.ts",
|
||||
outputDir: "../test-results/server-connect",
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL,
|
||||
browserName: "chromium",
|
||||
serviceWorkers: "block",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
cwd: "../..",
|
||||
url: baseURL,
|
||||
timeout: 120_000,
|
||||
env: { VITE_OPENCODE_SERVER_MODE: "none" },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { writeFile, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { encode } from "uqr"
|
||||
|
||||
const video = path.join(tmpdir(), `opencode-pairing-${process.pid}.y4m`)
|
||||
const pairing = {
|
||||
urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"],
|
||||
username: "opencode",
|
||||
password: "qr-test-password",
|
||||
}
|
||||
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--use-fake-ui-for-media-stream",
|
||||
`--use-file-for-fake-video-capture=${video}`,
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Feed the real decoder an inverted terminal QR through Chromium's virtual camera.
|
||||
const qr = encode(JSON.stringify(pairing), { border: 4, invert: true })
|
||||
const size = 640
|
||||
const scale = Math.floor(400 / qr.size)
|
||||
const offset = Math.floor((size - qr.size * scale) / 2)
|
||||
const luma = Buffer.from(
|
||||
Array.from({ length: size * size }, (_, index) => {
|
||||
const row = Math.floor((Math.floor(index / size) - offset) / scale)
|
||||
const column = Math.floor(((index % size) - offset) / scale)
|
||||
return qr.data[row]?.[column] === false ? 235 : 16
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
video,
|
||||
Buffer.concat([
|
||||
Buffer.from(`YUV4MPEG2 W${size} H${size} F10:1 Ip A1:1 C420jpeg\nFRAME\n`),
|
||||
luma,
|
||||
Buffer.alloc((size * size) / 2, 128),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test.afterAll(() => rm(video, { force: true }))
|
||||
|
||||
test("scans opencode pair into the address and password fields", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Scan QR code" }).click()
|
||||
await expect(page.getByLabel("Server address")).toHaveValue(pairing.urls[0])
|
||||
await expect(page.getByLabel("Password", { exact: true })).toHaveValue(pairing.password)
|
||||
await expect(page.getByLabel("Password", { exact: true })).toHaveAttribute("type", "password")
|
||||
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled()
|
||||
await expect(page.getByLabel("Pairing camera")).toHaveCount(0)
|
||||
})
|
||||
@@ -32,6 +32,7 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
|
||||
"test:server-connect": "playwright test --config e2e/server-connect/playwright.config.ts",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||
"bench:tabs": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
|
||||
@@ -51,6 +52,7 @@
|
||||
"diff": "catalog:",
|
||||
"happy-dom": "20.11.1",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"uqr": "0.1.3",
|
||||
"vite": "8.2.2",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
@@ -87,6 +89,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: [
|
||||
"service-worker/**",
|
||||
"server-connect/**",
|
||||
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
|
||||
],
|
||||
outputDir: "./e2e/test-results",
|
||||
|
||||
@@ -95,7 +95,7 @@ export function AppBaseProviders(
|
||||
|
||||
export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
defaultServer?: ServerConnection.Key
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
|
||||
+13
-11
@@ -76,22 +76,24 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
const standalone = isStandalone()
|
||||
root.dataset.standalone = String(standalone)
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: web.currentServerUrl,
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
const server: ServerConnection.Http | undefined = web.currentServerUrl
|
||||
? {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: web.currentServerUrl,
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={web.platform}>
|
||||
<AppBaseProviders locale={locale}>
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
|
||||
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
|
||||
servers={server ? [server] : []}
|
||||
>
|
||||
<KeyboardInsets />
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OPENCODE_SERVER_MODE?: "none" | "origin"
|
||||
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
|
||||
|
||||
@@ -198,26 +198,30 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
<Show
|
||||
when={props.servers.length > 1}
|
||||
fallback={
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
<Show when={props.dropdown}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="mt-1 disabled:opacity-60"
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
<Show when={props.servers[0]}>
|
||||
{(server) => (
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={server()} items={props.projects} />
|
||||
<Show when={props.dropdown}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="mt-1 disabled:opacity-60"
|
||||
disabled={props.serverHealth(server())?.healthy === false}
|
||||
onClick={() => props.onChooseProject(server())}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
|
||||
|
||||
@@ -311,8 +311,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
dialog.show(() => <DeleteDialog server={server} session={session} />),
|
||||
},
|
||||
tab: {
|
||||
isOpen: (record: HomeSessionRecord) =>
|
||||
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
|
||||
isOpen: (record: HomeSessionRecord) => {
|
||||
const server = home.selection.value().server
|
||||
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { HomeScrollController } from "../scroll"
|
||||
import type { HomeSessionSearchController } from "./search"
|
||||
import type { HomeSessionsController } from "./controller"
|
||||
import { HomeSessionsView } from "./view"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
export function HomeSessions(props: {
|
||||
sessions: HomeSessionsController
|
||||
@@ -9,43 +10,47 @@ export function HomeSessions(props: {
|
||||
scroll: HomeScrollController
|
||||
}) {
|
||||
return (
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups()}
|
||||
loading={props.sessions.data.loading()}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={props.sessions.session.server()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
searchValue={props.search.query.value()}
|
||||
searchPlaceholder={props.search.query.placeholder()}
|
||||
searchOpen={props.search.query.open()}
|
||||
searchLoading={props.search.result.loading()}
|
||||
searchResults={props.search.result.list()}
|
||||
searchActive={props.search.result.active()}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel()}
|
||||
titleOpacity={props.scroll.header.titleOpacity}
|
||||
isOpenTab={props.sessions.tab.isOpen}
|
||||
onCreateSession={props.sessions.session.create}
|
||||
onOpenSession={props.sessions.session.open}
|
||||
onArchiveSession={props.sessions.session.archive}
|
||||
onRenameSession={props.sessions.session.rename}
|
||||
onExportSession={props.sessions.session.export}
|
||||
onDeleteSession={props.sessions.session.showDelete}
|
||||
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
|
||||
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
|
||||
onSetContent={props.scroll.header.setContent}
|
||||
onSetHeader={props.scroll.header.setHeader}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onSetSearchRoot={props.search.element.setRoot}
|
||||
onSetSearchInput={props.search.element.setInput}
|
||||
onSetSearchList={props.search.element.setList}
|
||||
onSearchFocus={props.search.query.focus}
|
||||
onSearchInput={props.search.query.input}
|
||||
onSearchClose={props.search.query.close}
|
||||
onSearchMove={props.search.result.move}
|
||||
onSearchSelectActive={props.search.result.selectActive}
|
||||
onSearchHighlight={props.search.result.highlight}
|
||||
onSearchSelect={props.search.result.select}
|
||||
/>
|
||||
<Show when={props.sessions.session.server()}>
|
||||
{(server) => (
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups()}
|
||||
loading={props.sessions.data.loading()}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={server()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
searchValue={props.search.query.value()}
|
||||
searchPlaceholder={props.search.query.placeholder()}
|
||||
searchOpen={props.search.query.open()}
|
||||
searchLoading={props.search.result.loading()}
|
||||
searchResults={props.search.result.list()}
|
||||
searchActive={props.search.result.active()}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel()}
|
||||
titleOpacity={props.scroll.header.titleOpacity}
|
||||
isOpenTab={props.sessions.tab.isOpen}
|
||||
onCreateSession={props.sessions.session.create}
|
||||
onOpenSession={props.sessions.session.open}
|
||||
onArchiveSession={props.sessions.session.archive}
|
||||
onRenameSession={props.sessions.session.rename}
|
||||
onExportSession={props.sessions.session.export}
|
||||
onDeleteSession={props.sessions.session.showDelete}
|
||||
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
|
||||
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
|
||||
onSetContent={props.scroll.header.setContent}
|
||||
onSetHeader={props.scroll.header.setHeader}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onSetSearchRoot={props.search.element.setRoot}
|
||||
onSetSearchInput={props.search.element.setInput}
|
||||
onSetSearchList={props.search.element.setList}
|
||||
onSearchFocus={props.search.query.focus}
|
||||
onSearchInput={props.search.query.input}
|
||||
onSearchClose={props.search.query.close}
|
||||
onSearchMove={props.search.result.move}
|
||||
onSearchSelectActive={props.search.result.selectActive}
|
||||
onSearchHighlight={props.search.result.highlight}
|
||||
onSearchSelect={props.search.result.select}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -366,6 +366,20 @@ export const dict = {
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.password": "Password",
|
||||
"dialog.server.add.passwordPlaceholder": "password",
|
||||
|
||||
"server.connect.title": "Connect to a server",
|
||||
"server.connect.description": "Enter your server address and password to get started.",
|
||||
"server.connect.button": "Connect",
|
||||
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
|
||||
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
|
||||
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
|
||||
"server.connect.scan": "Scan QR code",
|
||||
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
|
||||
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
|
||||
"server.connect.camera": "Pairing camera",
|
||||
"server.connect.camera.starting": "Opening camera…",
|
||||
"server.connect.camera.error":
|
||||
"Could not open the camera. Allow camera access or enter your connection details manually.",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
|
||||
}
|
||||
|
||||
function getCurrentServerUrl() {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
|
||||
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
|
||||
@@ -194,7 +194,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
name: "Server",
|
||||
gate: true,
|
||||
init: (props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
defaultServer?: ServerConnection.Key
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodePairingCode, serverAddress } from "./pairing"
|
||||
|
||||
describe("pairing code", () => {
|
||||
test("reads the JSON payload emitted by opencode pair", () => {
|
||||
expect(
|
||||
decodePairingCode(
|
||||
JSON.stringify({
|
||||
urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"],
|
||||
username: "opencode",
|
||||
password: "test:password",
|
||||
}),
|
||||
),
|
||||
).toEqual({ urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"], password: "test:password" })
|
||||
})
|
||||
|
||||
test("normalizes and deduplicates addresses while dropping invalid addresses", () => {
|
||||
expect(
|
||||
decodePairingCode(
|
||||
JSON.stringify({
|
||||
urls: ["https://server.example/", "https://server.example", "file:///etc/passwd"],
|
||||
username: "opencode",
|
||||
password: "test-password",
|
||||
}),
|
||||
),
|
||||
).toEqual({ urls: ["https://server.example"], password: "test-password" })
|
||||
})
|
||||
|
||||
test.each([
|
||||
"not json",
|
||||
"null",
|
||||
"[]",
|
||||
JSON.stringify({ urls: [], username: "opencode", password: "test" }),
|
||||
JSON.stringify({ urls: ["file:///etc/passwd"], username: "opencode", password: "test" }),
|
||||
JSON.stringify({ urls: [42], username: "opencode", password: "test" }),
|
||||
JSON.stringify({ urls: ["https://server.example"], username: "someone", password: "test" }),
|
||||
JSON.stringify({ urls: ["https://server.example"], username: "opencode", password: 42 }),
|
||||
JSON.stringify({ urls: ["https://server.example"], username: "opencode" }),
|
||||
])("rejects an unrelated or malformed QR payload: %s", (value) => {
|
||||
expect(decodePairingCode(value)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("server address", () => {
|
||||
test("accepts a bare address and preserves an explicit server path", () => {
|
||||
expect(serverAddress(" 192.168.1.20:4096/ ")).toBe("http://192.168.1.20:4096")
|
||||
expect(serverAddress("https://server.example/opencode/")).toBe("https://server.example/opencode")
|
||||
})
|
||||
|
||||
test.each([
|
||||
"",
|
||||
"http://",
|
||||
"file:///tmp/server",
|
||||
"https://user:password@server.example",
|
||||
"https://server.example?token=secret",
|
||||
"https://server.example#fragment",
|
||||
])("rejects invalid or embedded connection data: %s", (value) => {
|
||||
expect(serverAddress(value)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
export function serverAddress(value: string) {
|
||||
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized || !URL.canParse(normalized)) return
|
||||
const url = new URL(normalized)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return
|
||||
if (url.username || url.password || url.search || url.hash) return
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string) {
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import QrScanner from "qr-scanner"
|
||||
import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ error: "", ready: false })
|
||||
const video = document.createElement("video")
|
||||
video.setAttribute("aria-label", language.t("server.connect.camera"))
|
||||
video.setAttribute("playsinline", "")
|
||||
video.muted = true
|
||||
|
||||
onMount(() => {
|
||||
// QrScanner hides detached videos, so initialize only after this preview is mounted.
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
const pairing = decodePairingCode(result.data)
|
||||
if (!pairing) {
|
||||
setState("error", language.t("server.connect.scan.invalid"))
|
||||
return
|
||||
}
|
||||
scanner.stop()
|
||||
props.onScan(pairing)
|
||||
},
|
||||
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
|
||||
)
|
||||
// Terminal QR codes can be light-on-dark depending on the terminal theme.
|
||||
scanner.setInversionMode("both")
|
||||
onCleanup(() => scanner.destroy())
|
||||
void scanner.start().then(
|
||||
() => setState("ready", true),
|
||||
() => setState("error", language.t("server.connect.camera.error")),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<section class="server-connect-scanner" aria-label={language.t("server.connect.scan")}>
|
||||
<p>{language.t("server.connect.scan.description")}</p>
|
||||
<div class="server-connect-video">
|
||||
{video}
|
||||
<Show when={!state.ready && !state.error}>
|
||||
<span role="status">{language.t("server.connect.camera.starting")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={state.error}>
|
||||
<p class="server-connect-error" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
</Show>
|
||||
<Button variant="neutral" size="large" onClick={props.onCancel}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
[data-component="connect-server"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
|
||||
max(32px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
|
||||
background: var(--v2-background-bg-base);
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
.server-connect-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 360px);
|
||||
margin: auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.server-connect-brand {
|
||||
width: 160px;
|
||||
margin-inline: auto;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 530;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
form,
|
||||
.server-connect-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.server-connect-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
[data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
[data-slot="text-input-v2-input"] {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-connect-error {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-block-start: 16px;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
margin-block-start: 12px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { lazy, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
export function ConnectServerScreen() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const url = serverAddress(state.url)
|
||||
if (!url) {
|
||||
setState("error", language.t("server.connect.address.invalid"))
|
||||
return
|
||||
}
|
||||
const http = { url, password: state.password || undefined }
|
||||
const result = await check(http)
|
||||
if (!result.healthy) {
|
||||
setState("error", language.t("server.connect.failed"))
|
||||
return
|
||||
}
|
||||
servers.add({ type: "http", http })
|
||||
},
|
||||
onError: () => setState("error", language.t("server.connect.failed")),
|
||||
}))
|
||||
|
||||
return (
|
||||
<main data-component="connect-server" aria-labelledby="server-connect-title">
|
||||
<div class="server-connect-content">
|
||||
<div class="server-connect-brand" role="img" aria-label="OpenCode">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<header>
|
||||
<h1 id="server-connect-title">{language.t("server.connect.title")}</h1>
|
||||
<p>{language.t("server.connect.description")}</p>
|
||||
</header>
|
||||
<Show
|
||||
when={!state.scanning}
|
||||
fallback={
|
||||
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
|
||||
<PairingScanner
|
||||
onCancel={() => setState("scanning", false)}
|
||||
onScan={(pairing) =>
|
||||
setState({
|
||||
url: pairing.urls[0],
|
||||
urls: pairing.urls,
|
||||
password: pairing.password,
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (request.isPending) return
|
||||
setState("error", "")
|
||||
request.mutate()
|
||||
}}
|
||||
>
|
||||
<div class="server-connect-field">
|
||||
<label for="server-connect-url">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
id="server-connect-url"
|
||||
name="server"
|
||||
dir="ltr"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
autocomplete="url"
|
||||
autocapitalize="off"
|
||||
spellcheck={false}
|
||||
required
|
||||
appearance="large"
|
||||
list="server-connect-addresses"
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
value={state.url}
|
||||
disabled={request.isPending}
|
||||
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
|
||||
/>
|
||||
<datalist id="server-connect-addresses">
|
||||
{state.urls.map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="server-connect-field">
|
||||
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
id="server-connect-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
appearance="large"
|
||||
value={state.password}
|
||||
disabled={request.isPending}
|
||||
onInput={(event) => setState({ password: event.currentTarget.value, error: "" })}
|
||||
/>
|
||||
</div>
|
||||
<Show when={state.error}>
|
||||
<p class="server-connect-error" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
</Show>
|
||||
<Button type="submit" variant="contrast" size="large" disabled={request.isPending || !state.url.trim()}>
|
||||
{language.t(request.isPending ? "dialog.server.add.checking" : "server.connect.button")}
|
||||
</Button>
|
||||
</form>
|
||||
<Show when={platform.platform === "web"}>
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
disabled={request.isPending}
|
||||
onClick={() => setState("scanning", true)}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
</Show>
|
||||
<footer>
|
||||
<p>{language.t("server.connect.pair.description")}</p>
|
||||
<code dir="ltr">opencode pair</code>
|
||||
</footer>
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { LayoutProvider } from "@/shell/state/layout"
|
||||
@@ -15,6 +15,9 @@ export const File = lazy(() => import("@opencode-ai/session-ui/file").then((modu
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
|
||||
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
|
||||
const ConnectServerScreen = lazy(() =>
|
||||
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
|
||||
)
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
@@ -71,13 +74,16 @@ function TargetServerRoute(props: ParentProps) {
|
||||
}
|
||||
|
||||
function AppLayout(props: ParentProps) {
|
||||
const servers = useServers()
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ describe("layout persistence", () => {
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
|
||||
test("restores layout without an initial server and retains a saved selection", () => {
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout())
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const empty = decode({})
|
||||
expect(empty.home.selection).toEqual({})
|
||||
expect(decode(Schema.encodeSync(schema)(empty)).home.selection).toEqual({})
|
||||
|
||||
const selection = { server: ServerConnection.Key.make("https://server.example.test"), directory: "/project" }
|
||||
const saved = decode({ home: { selection } })
|
||||
expect(saved.home.selection).toEqual(selection)
|
||||
expect(decode(Schema.encodeSync(schema)(saved)).home.selection).toEqual(selection)
|
||||
})
|
||||
|
||||
test("uses supplied initial preferences after legacy migration", () => {
|
||||
const initial = initialLayout(ServerConnection.Key.make("remote"))
|
||||
initial.sidebar.width = 420
|
||||
|
||||
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
|
||||
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
|
||||
home: Persistence.struct({
|
||||
selection: Persistence.struct({
|
||||
server: TabStorage.ServerKey,
|
||||
server: Schema.optional(TabStorage.ServerKey),
|
||||
directory: Schema.optional(Schema.String),
|
||||
}),
|
||||
}),
|
||||
@@ -223,7 +223,7 @@ export const layoutPersistence = Persistence.migrate(
|
||||
),
|
||||
)
|
||||
|
||||
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
|
||||
export function initialLayout(server?: ServerConnection.Key): typeof layoutSchema.Type {
|
||||
return {
|
||||
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
|
||||
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
|
||||
@@ -233,7 +233,7 @@ export function initialLayout(server: ServerConnection.Key): typeof layoutSchema
|
||||
mobileSidebar: { opened: false },
|
||||
sessionTabs: {},
|
||||
sessionView: {},
|
||||
home: { selection: { server } },
|
||||
home: { selection: server ? { server } : {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...Persist.global("layout"), previousKey: "layout.v6" },
|
||||
layoutPersistence,
|
||||
initialLayout(ServerConnection.key(servers.list[0])),
|
||||
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
|
||||
)
|
||||
const [ephemeral, setEphemeral] = createStore({
|
||||
reviewPanelSource: "other" as ReviewPanelSource,
|
||||
|
||||
@@ -6,7 +6,9 @@ import { collectFiles } from "./files"
|
||||
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
||||
if (options?.skipBuild) return compress({})
|
||||
const root = path.resolve(import.meta.dirname, "../../app")
|
||||
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||
await $`bun run build`
|
||||
.cwd(root)
|
||||
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
|
||||
const assets = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await collectFiles(path.join(root, "dist")))
|
||||
|
||||
Reference in New Issue
Block a user