diff --git a/packages/app/e2e/regression/home.spec.ts b/packages/app/e2e/regression/home.spec.ts index 81c61af76f9..dee6a3655ce 100644 --- a/packages/app/e2e/regression/home.spec.ts +++ b/packages/app/e2e/regression/home.spec.ts @@ -61,6 +61,19 @@ test("a working Home session keeps its project avatar and shows its activity rin expect(box!.x).toBeGreaterThan(title!.x + title!.width) }) +test("Home lists projects and sessions when stored state hides every server", async ({ page }) => { + await mockStressTimeline(page) + await seed(page, { usedProjects: { local: [fixture.project.id] } }) + // A second seed merges into the server entry the first one wrote. + await seed(page, { storage: { "opencode.global.dat:server": { hidden: { [fixture.serverKey]: true } } } }) + await page.goto("/") + + await expect( + page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name }), + ).toBeVisible() + await expect(row(page, fixture.expected.targetTitle)).toBeVisible() +}) + test("the session context menu renames, exports, and deletes a Home session", async ({ page }) => { await openHome(page) const target = row(page, fixture.expected.targetTitle) diff --git a/packages/app/src/runtime/server/registry.test.ts b/packages/app/src/runtime/server/registry.test.ts index b7b41d44a65..00d11271dc8 100644 --- a/packages/app/src/runtime/server/registry.test.ts +++ b/packages/app/src/runtime/server/registry.test.ts @@ -1,5 +1,12 @@ import { expect, test } from "bun:test" -import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection, serverLabel } from "./registry" +import { + canRemoveServer, + createServerProjects, + resolveServerList, + resolveVisibleServers, + ServerConnection, + serverLabel, +} from "./registry" import { Schema } from "effect" import { ServerState } from "./persistence" import { createStore } from "solid-js/store" @@ -99,6 +106,21 @@ test("keeps exact persisted server identities and prevents removing provided ser expect(canRemoveServer({ key, stored, provided: [{ type: "http", http: { url: key } }] })).toBe(false) }) +test("hidden servers are left out, but never every server: the built-in one, otherwise the first, stays shown", () => { + const sidecar: ServerConnection.Sidecar = { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } + const remote: ServerConnection.Http = { type: "http", http: { url: "https://apollo.example.test" } } + const other: ServerConnection.Http = { type: "http", http: { url: "https://zeus.example.test" } } + + const hide = (...servers: ServerConnection.Any[]) => + Object.fromEntries(servers.map((server) => [ServerConnection.key(server), true])) + + expect(resolveVisibleServers({ servers: [sidecar, remote], hidden: {} })).toEqual([sidecar, remote]) + expect(resolveVisibleServers({ servers: [sidecar, remote], hidden: hide(sidecar) })).toEqual([remote]) + expect(resolveVisibleServers({ servers: [remote, sidecar], hidden: hide(remote, sidecar) })).toEqual([sidecar]) + expect(resolveVisibleServers({ servers: [remote, other], hidden: hide(remote, other) })).toEqual([remote]) + expect(resolveVisibleServers({ servers: [], hidden: hide(remote) })).toEqual([]) +}) + /** The server props a project store follows, which the test changes. */ type ProjectScopeProps = { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index 138f77f98f7..1255b36a9d0 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -133,6 +133,21 @@ export function canRemoveServer(input: { return input.stored.some((server) => server.http.url === input.key) } +/** + * The servers Home lists. Home needs at least one, so when every server is flagged hidden (stored by an older + * build, or edited by hand) the built-in server, otherwise the first, is shown anyway. + */ +export function resolveVisibleServers(input: { + servers: Array + hidden: StoredState["hidden"] +}): Array { + const visible = input.servers.filter((conn) => !input.hidden[ServerConnection.key(conn)]) + + if (visible.length > 0 || input.servers.length === 0) return visible + + return [input.servers.find(ServerConnection.builtin) ?? input.servers[0]] +} + export namespace ServerConnection { type Base = { displayName?: string; label?: string } @@ -229,7 +244,16 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex return resolveServerList({ stored: store.list, props: props.servers }) }) - const visibleServers = createMemo(() => allServers().filter((conn) => !store.hidden[ServerConnection.key(conn)])) + const visibleServers = createMemo(() => resolveVisibleServers({ servers: allServers(), hidden: store.hidden })) + + // Clears the hidden flag of any server shown despite it, so the stored flags say what Home shows. + function unhideShown(servers: Array) { + for (const conn of resolveVisibleServers({ servers, hidden: store.hidden })) { + const key = ServerConnection.key(conn) + + if (store.hidden[key]) setStore("hidden", key, false) + } + } function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) @@ -254,9 +278,18 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex const list = store.list.filter((x) => x.http.url !== key) batch(() => { setStore("list", list) + // Removing the only shown server must not leave the rest all hidden. + unhideShown(resolveServerList({ stored: list, props: props.servers })) }) } + function setHidden(key: ServerConnection.Key, hidden: boolean) { + // Home always lists at least one server, so hiding the only shown one does nothing. + if (hidden && visibleServers().every((conn) => ServerConnection.key(conn) === key)) return + + setStore("hidden", key, hidden) + } + function canRemove(key: ServerConnection.Key) { return canRemoveServer({ key, provided: props.servers, stored: store.list }) } @@ -300,11 +333,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex // state wait on this, but the provider must not block first render on storage. hydrated, isHidden(key: ServerConnection.Key) { - return store.hidden[key] ?? false - }, - setHidden(key: ServerConnection.Key, hidden: boolean) { - setStore("hidden", key, hidden) + return !!store.hidden[key] && !visibleServers().some((conn) => ServerConnection.key(conn) === key) }, + setHidden, add, remove, canRemove,