From 4c6f8402bae02572fe7cb393f8210d78684c9fb8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 28 May 2026 14:48:15 -0700 Subject: [PATCH 1/2] Add app server connection selector --- packages/app/package.json | 1 + packages/app/src/routes/__root.tsx | 47 +- .../app/src/web/server-connection-menu.tsx | 469 ++++++++++++++++++ packages/app/src/web/shell.tsx | 8 +- 4 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/web/server-connection-menu.tsx diff --git a/packages/app/package.json b/packages/app/package.json index 876c1d26e..e14e9e288 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -24,6 +24,7 @@ "@executor-js/vite-plugin": "workspace:*", "@tanstack/react-router": "catalog:", "effect": "catalog:", + "lucide-react": "^1.7.0", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index abcef11d7..5d6fd4eba 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -1,9 +1,12 @@ import React from "react"; import { createRootRoute } from "@tanstack/react-router"; import { ExecutorProvider } from "@executor-js/react/api/provider"; +import { useExecutorServerConnection } from "@executor-js/react/api/server-connection"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { Button } from "@executor-js/react/components/button"; import { Toaster } from "@executor-js/react/components/sonner"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; +import { ServerConnectionMenu } from "../web/server-connection-menu"; import { Shell } from "../web/shell"; export const Route = createRootRoute({ @@ -12,7 +15,7 @@ export const Route = createRootRoute({ function RootComponent() { return ( - + }> @@ -20,3 +23,45 @@ function RootComponent() { ); } + +function ShellConnectionError() { + const connection = useExecutorServerConnection(); + return ( +
+ + +
+
+
+ +
+

+ Server unavailable +

+

+ Could not connect to Executor +

+

+ The selected server did not answer the initial scope request. Switch servers or retry + this connection. +

+ + {connection.origin} + + +
+
+
+ ); +} diff --git a/packages/app/src/web/server-connection-menu.tsx b/packages/app/src/web/server-connection-menu.tsx new file mode 100644 index 000000000..6f0bfd614 --- /dev/null +++ b/packages/app/src/web/server-connection-menu.tsx @@ -0,0 +1,469 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ServerIcon } from "lucide-react"; +import { + getExecutorServerAuthorizationHeader, + useExecutorServerConnection, + useSetExecutorServerConnection, + type ExecutorServerAuth, + type ExecutorServerConnection, + type ExecutorServerConnectionInput, +} from "@executor-js/react/api/server-connection"; +import { + EXECUTOR_SERVER_PROFILES_STORAGE_KEY, + getActiveExecutorServerProfile, + normalizeExecutorServerProfilesSnapshot, + parseExecutorServerProfilesSnapshot, + readExecutorServerProfiles, + removeExecutorServerProfile, + selectExecutorServerProfile, + serializeExecutorServerProfilesSnapshot, + upsertExecutorServerProfile, + writeExecutorServerProfiles, + type ExecutorServerProfilesSnapshot, +} from "@executor-js/react/api/server-profiles"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; +import { + Popover, + PopoverContent, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "@executor-js/react/components/popover"; +import { cn } from "@executor-js/react/lib/utils"; + +type AuthMode = "none" | "bearer" | "basic"; + +interface DraftProfile { + readonly origin: string; + readonly displayName: string; + readonly authMode: AuthMode; + readonly username: string; + readonly secret: string; +} + +const emptyDraft: DraftProfile = { + origin: "", + displayName: "", + authMode: "none", + username: "executor", + secret: "", +}; + +interface DesktopProfileStorageBridge { + readonly getServerProfiles: () => Promise; + readonly setServerProfiles: (value: string) => Promise; +} + +const browserStorage = () => (typeof window === "undefined" ? null : window.localStorage); + +const desktopProfileStorageBridge = (): DesktopProfileStorageBridge | null => { + if (typeof window === "undefined") return null; + const bridge = window.executor; + if ( + !bridge || + typeof bridge.getServerProfiles !== "function" || + typeof bridge.setServerProfiles !== "function" + ) { + return null; + } + return { + getServerProfiles: bridge.getServerProfiles, + setServerProfiles: bridge.setServerProfiles, + }; +}; + +const readBrowserProfiles = (): ExecutorServerProfilesSnapshot => + readExecutorServerProfiles(browserStorage()); + +const clearBrowserProfiles = (): void => { + if (typeof window === "undefined") return; + window.localStorage.removeItem(EXECUTOR_SERVER_PROFILES_STORAGE_KEY); +}; + +const readStoredProfiles = (): Promise => { + const bridge = desktopProfileStorageBridge(); + const browserProfiles = readBrowserProfiles(); + if (!bridge) return Promise.resolve(browserProfiles); + + return bridge.getServerProfiles().then( + (raw) => { + const desktopProfiles = parseExecutorServerProfilesSnapshot(raw); + if (desktopProfiles.profiles.length > 0) { + if (browserProfiles.profiles.length > 0) { + const mergedProfiles = normalizeExecutorServerProfilesSnapshot({ + activeKey: desktopProfiles.activeKey ?? browserProfiles.activeKey, + profiles: [...browserProfiles.profiles, ...desktopProfiles.profiles], + }); + void bridge + .setServerProfiles(serializeExecutorServerProfilesSnapshot(mergedProfiles)) + .then(clearBrowserProfiles, () => undefined); + return mergedProfiles; + } + clearBrowserProfiles(); + return desktopProfiles; + } + if (browserProfiles.profiles.length > 0) { + void bridge + .setServerProfiles(serializeExecutorServerProfilesSnapshot(browserProfiles)) + .then(clearBrowserProfiles, () => undefined); + return browserProfiles; + } + return desktopProfiles; + }, + () => browserProfiles, + ); +}; + +const writeStoredProfiles = (snapshot: ExecutorServerProfilesSnapshot): void => { + const bridge = desktopProfileStorageBridge(); + if (!bridge) { + writeExecutorServerProfiles(browserStorage(), snapshot); + return; + } + + void bridge + .setServerProfiles(serializeExecutorServerProfilesSnapshot(snapshot)) + .then(clearBrowserProfiles, () => undefined); +}; + +const serverLabel = (connection: ExecutorServerConnection): string => + connection.displayName || connection.origin.replace(/^https?:\/\//, ""); + +const serverDescription = (connection: ExecutorServerConnection): string => + connection.origin.replace(/^https?:\/\//, ""); + +const serverKindLabel = (connection: ExecutorServerConnection): string => { + if (connection.kind === "desktop-sidecar") return "Desktop"; + const hostname = new URL(connection.origin).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ? "Local" + : "Remote"; +}; + +const authLabel = (connection: ExecutorServerConnection): string => { + const authorization = getExecutorServerAuthorizationHeader(connection); + if (!authorization) return "No auth"; + if (authorization.startsWith("Bearer ")) return "Bearer"; + if (authorization.startsWith("Basic ")) return "Basic"; + return "Auth"; +}; + +const snapshotWithCurrent = ( + snapshot: ExecutorServerProfilesSnapshot, + connection: ExecutorServerConnection, + makeActive: boolean, +): ExecutorServerProfilesSnapshot => + upsertExecutorServerProfile(snapshot, connection, { makeActive }) ?? snapshot; + +const draftAuth = (draft: DraftProfile): ExecutorServerAuth | undefined => { + const secret = draft.secret.trim(); + if (draft.authMode === "bearer" && secret) { + return { kind: "bearer", token: secret }; + } + if (draft.authMode === "basic" && secret) { + return { + kind: "basic", + username: draft.username.trim() || undefined, + password: secret, + }; + } + return undefined; +}; + +interface ServerConnectionMenuProps { + readonly side?: "top" | "right" | "bottom" | "left"; + readonly align?: "start" | "center" | "end"; + readonly variant?: "default" | "header"; +} + +export function ServerConnectionMenu(props: ServerConnectionMenuProps = {}) { + const connection = useExecutorServerConnection(); + const setServerConnection = useSetExecutorServerConnection(); + const hydratedRef = useRef(false); + const [hydrated, setHydrated] = useState(false); + const [snapshot, setSnapshot] = useState(() => ({ + activeKey: connection.key, + profiles: [connection], + })); + const [draft, setDraft] = useState(emptyDraft); + const [error, setError] = useState(null); + const [showCustomServer, setShowCustomServer] = useState(false); + + const persistSnapshot = useCallback((next: ExecutorServerProfilesSnapshot) => { + setSnapshot(next); + writeStoredProfiles(next); + }, []); + + useEffect(() => { + if (hydratedRef.current) return; + hydratedRef.current = true; + + let cancelled = false; + void readStoredProfiles().then((stored) => { + if (cancelled) return; + const storedActive = getActiveExecutorServerProfile(stored); + const next = snapshotWithCurrent(stored, connection, storedActive === null); + persistSnapshot(next); + if (storedActive && storedActive.key !== connection.key) { + setServerConnection(storedActive); + } + setHydrated(true); + }); + + return () => { + cancelled = true; + }; + }, [connection, persistSnapshot, setServerConnection]); + + useEffect(() => { + if (!hydrated) return; + setSnapshot((previous) => { + const next = snapshotWithCurrent(previous, connection, true); + writeStoredProfiles(next); + return next; + }); + }, [connection, hydrated]); + + const selectProfile = (key: string): void => { + const next = selectExecutorServerProfile(snapshot, key); + const active = getActiveExecutorServerProfile(next); + persistSnapshot(next); + if (active) setServerConnection(active); + }; + + const removeProfile = (key: string): void => { + if (snapshot.profiles.length <= 1) return; + const next = removeExecutorServerProfile(snapshot, key); + const active = getActiveExecutorServerProfile(next); + persistSnapshot(next); + if (key === connection.key && active) setServerConnection(active); + }; + + const addProfile = (event: React.FormEvent): void => { + event.preventDefault(); + const origin = draft.origin.trim(); + if (!origin) { + setError("Enter a server origin."); + return; + } + if (draft.authMode !== "none" && !draft.secret.trim()) { + setError("Enter the credential value."); + return; + } + + const auth = draftAuth(draft); + const input: ExecutorServerConnectionInput = { + kind: "http", + origin, + ...(draft.displayName.trim() ? { displayName: draft.displayName.trim() } : {}), + ...(auth ? { auth } : {}), + }; + const next = upsertExecutorServerProfile(snapshot, input); + const active = next ? getActiveExecutorServerProfile(next) : null; + if (!next || !active) { + setError("Enter a valid http or https origin."); + return; + } + + setError(null); + setDraft(emptyDraft); + setShowCustomServer(false); + persistSnapshot(next); + setServerConnection(active); + }; + const trigger = + props.variant === "header" ? ( + + ) : ( + + ); + + return ( + + {trigger} + + + Server profiles + + +
+ {snapshot.profiles.map((profile) => { + const active = profile.key === connection.key; + const profileAuthLabel = authLabel(profile); + return ( +
+ + + {serverKindLabel(profile)} + + {profileAuthLabel !== "No auth" && ( + + {profileAuthLabel} + + )} + +
+ ); + })} +
+ +
+ + + {showCustomServer && ( +
+
+ + + + {draft.authMode === "basic" && ( + + )} + {draft.authMode !== "none" && ( + + )} + {error &&

{error}

} + +
+
+ )} +
+
+
+ ); +} diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 204118360..8de519220 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -10,6 +10,7 @@ import { Button } from "@executor-js/react/components/button"; import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon"; import { CommandPalette } from "@executor-js/react/components/command-palette"; import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client"; +import { ServerConnectionMenu } from "./server-connection-menu"; // ── Env ───────────────────────────────────────────────────────────────── @@ -356,13 +357,16 @@ function SidebarContent(props: { return ( <> {props.showBrand !== false && ( -
- +
+ executor Beta +
+ +
)} From 4d4f02eb53b58c29f25e6f9ad863a300e85aebe1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 28 May 2026 18:30:37 -0700 Subject: [PATCH 2/2] Avoid window typeof checks in server menu --- packages/app/src/web/server-connection-menu.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/app/src/web/server-connection-menu.tsx b/packages/app/src/web/server-connection-menu.tsx index 6f0bfd614..0b9585825 100644 --- a/packages/app/src/web/server-connection-menu.tsx +++ b/packages/app/src/web/server-connection-menu.tsx @@ -57,11 +57,10 @@ interface DesktopProfileStorageBridge { readonly setServerProfiles: (value: string) => Promise; } -const browserStorage = () => (typeof window === "undefined" ? null : window.localStorage); +const browserStorage = () => globalThis.window?.localStorage ?? null; const desktopProfileStorageBridge = (): DesktopProfileStorageBridge | null => { - if (typeof window === "undefined") return null; - const bridge = window.executor; + const bridge = globalThis.window?.executor; if ( !bridge || typeof bridge.getServerProfiles !== "function" || @@ -79,8 +78,7 @@ const readBrowserProfiles = (): ExecutorServerProfilesSnapshot => readExecutorServerProfiles(browserStorage()); const clearBrowserProfiles = (): void => { - if (typeof window === "undefined") return; - window.localStorage.removeItem(EXECUTOR_SERVER_PROFILES_STORAGE_KEY); + browserStorage()?.removeItem(EXECUTOR_SERVER_PROFILES_STORAGE_KEY); }; const readStoredProfiles = (): Promise => {