diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx index 6474c2208c78942d500e5db72fe8db6f2cb2fa36..edefc54914d8fe0a8cd05179f39ee05733642efe 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -175,7 +175,7 @@ describe("cloud onboarding discovery", () => { finishDiscovery(linkedMachines); }); expect(onDiscoveryReady).toHaveBeenCalledTimes(1); - expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + expect(renderer!.root.findByType("button").children).toEqual(["Add"]); }); it("connects and selects discovered computers by default without overwriting deselection", async () => { @@ -232,7 +232,7 @@ describe("cloud onboarding discovery", () => { expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ "Work laptop", ]); - expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + expect(renderer!.root.findByType("button").children).toEqual(["Add"]); await advance(30_000); expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); }); @@ -240,7 +240,7 @@ describe("cloud onboarding discovery", () => { it("keeps a discovered computer visible when it is added to the browser", async () => { discovery.listEnvironments.mockResolvedValue(linkedMachines); await mount(); - expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + expect(renderer!.root.findByType("button").children).toEqual(["Add"]); await act(async () => { renderer!.update( 0} onClick={() => void connectEnvironment(environment)} > - {connectingEnvironmentIds.has(environment.environmentId) ? "Connecting…" : "Connect"} + {connectingEnvironmentIds.has(environment.environmentId) ? "Adding…" : "Add"} )} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index a6e29cf7231284e5be9b16702fbd77c97d883887..c450e849307bf72d149d820f8c88a9774282fc40 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -6,7 +6,6 @@ import { TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { useLocation } from "@tanstack/react-router"; import { Atom } from "effect/unstable/reactivity"; import { type KeyboardEvent, @@ -42,7 +41,7 @@ import { type EnvironmentId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -67,8 +66,14 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; -import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; -import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings"; +import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; +import { + EnvironmentRow, + environmentTransportLabel, + formatDesktopSshTarget, +} from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; @@ -106,7 +111,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; import { Spinner } from "../ui/spinner"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -158,7 +163,7 @@ import { import { requestConfirmDialog } from "~/confirmDialog"; import { useAtomCommand } from "../../state/use-atom-command"; import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; -import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress, @@ -288,11 +293,6 @@ function AccessScopeSummary({ ); } -function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { - const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; - return target.port ? `${authority}:${target.port}` : authority; -} - function parseManualDesktopSshTarget(input: { readonly host: string; readonly username: string; @@ -436,6 +436,20 @@ function sortDesktopPairingLinks(links: ReadonlyArray) ); } +/** Closed-header summary for the Authorized clients fold. */ +function summarizeAuthorizedClients( + sessions: ReadonlyArray, + links: ReadonlyArray, +): string { + const parts = [ + `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`, + links.length > 0 + ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}` + : null, + ]; + return parts.filter((part): part is string => part !== null).join(" · "); +} + function sortDesktopClientSessions(sessions: ReadonlyArray) { return [...sessions].toSorted((left, right) => { if (left.current !== right.current) { @@ -1419,6 +1433,44 @@ type SavedBackendListRowProps = { onRemove: (environment: EnvironmentPresentation) => void; }; +/** + * Status word for a row subtitle: "Reconnecting: " instead of the + * long-form sentence, since the row has one line and the full text is one + * hover away. + */ +function savedBackendStatus(environment: EnvironmentPresentation): { + readonly text: string; + readonly tone: "muted" | "error"; +} { + if (!environment.entry.enabled) return { text: "Off", tone: "muted" }; + const { connection } = environment; + switch (connection.phase) { + case "connected": + return { text: "Connected", tone: "muted" }; + case "connecting": + return { text: "Connecting", tone: "muted" }; + case "reconnecting": + return { + text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", + tone: "error", + }; + case "error": + return { + text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed", + tone: "error", + }; + case "offline": + return { text: "Offline", tone: "muted" }; + case "available": + return { text: "Not connected", tone: "muted" }; + } +} + +/** + * One added machine in the Environments list. The switch is the main action; + * the update icon appears only when that machine can take an update; the + * row menu holds the icon override, trace ID, and removal. + */ function SavedBackendListRow({ environment, removingEnvironmentId, @@ -1427,19 +1479,8 @@ function SavedBackendListRow({ }: SavedBackendListRowProps) { const environmentId = environment.environmentId; const enabled = environment.entry.enabled; - const connectionState = environment.connection.phase; - const isConnected = connectionState === "connected"; + const isConnected = environment.connection.phase === "connected"; const isRemoving = removingEnvironmentId === environmentId; - const stateDotClassName = !enabled - ? "bg-muted-foreground/40" - : connectionState === "connected" - ? "bg-success" - : connectionState === "connecting" || connectionState === "reconnecting" - ? "bg-warning" - : connectionState === "error" - ? "bg-destructive" - : "bg-muted-foreground/40"; - const statusTooltip = enabled ? connectionStatusText(environment.connection) : "Off"; const errorTraceId = environment.connection.traceId; const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ target: "trace ID", @@ -1470,25 +1511,18 @@ function SavedBackendListRow({ const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); const resumingServerUpdate = serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; - const sshTarget = - environment.entry.target._tag === "SshConnectionTarget" && - Option.isSome(environment.entry.profile) && - environment.entry.profile.value._tag === "SshConnectionProfile" - ? environment.entry.profile.value.target - : null; - const metadataBits = [ - sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, - environment.relayManaged ? "T3 Connect" : null, - enabled ? null : "Off", - ].filter((value): value is string => value !== null); + const status = savedBackendStatus(environment); + const serverVersion = environment.serverConfig?.environment.serverVersion ?? null; + const subtitleText = [ + environmentTransportLabel(environment), + resumingServerUpdate ? "Restarting" : status.text, + enabled && versionMismatch ? serverVersion : null, + ] + .filter((value): value is string => value !== null) + .join(" · "); - // The WSL backend is a desktop-managed local backend (it surfaces as a bearer - // environment whose connection id is prefixed "local:"), not a remote - // environment you connect to or remove here — its lifecycle is driven by the - // WSL on/off + distro picker on this page. - const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target); // Only a connected, enabled machine can take a remote update; a switched-off - // one keeps the "update available" note so the icon is not a surprise later. + // one keeps the version note so the icon is not a surprise later. const showUpdateAction = enabled && isConnected && @@ -1496,153 +1530,96 @@ function SavedBackendListRow({ (serverUpdateState.status === "idle" || serverUpdateState.status === "failed"); return ( -
-
-
-
-
- + - -

- {environment.label} -

-
- {isConnected ? ( -
- -
- ) : null} + } + > + {subtitleText} + + + {enabled ? connectionStatusText(environment.connection) : "Switched off"} + {versionMismatch + ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` + : ""} + + + } + below={ + serverUpdateState.status !== "idle" ? ( +
+
- {metadataBits.length > 0 ? ( -

{metadataBits.join(" · ")}

- ) : null} - {serverUpdateState.status !== "idle" ? ( -
- -
- ) : versionMismatch ? ( - - - Server update available - - } - /> - - {versionMismatch.serverVersion} {" "} - {versionMismatch.clientVersion} - - - ) : null} - {enabled && environment.connection.error && !resumingServerUpdate ? ( -

- - {connectionStatusText(environment.connection)} - - {errorTraceId ? ( - - ) : null} -

- ) : null} -
-
- {showUpdateAction ? ( - + {showUpdateAction ? ( + + ) : null} + + onSetEnabled(environmentId, checked)} + /> + } + /> + {enabled ? "Switch off" : "Switch on"} + + + + } + > + + + + + {errorTraceId ? ( + copyTraceId(errorTraceId)}>Copy trace ID ) : null} - {isWslEnvironment ? ( - - - Managed locally - - } - /> - - Select the primary environment to turn the WSL backend on or off. - - - ) : ( - <> - - onSetEnabled(environmentId, checked)} - /> - } - /> - {enabled ? "Switch off" : "Switch on"} - - - - } - > - - - - {errorTraceId ? ( - copyTraceId(errorTraceId)}>Copy trace ID - ) : null} - onRemove(environment)}> - {isRemoving ? "Removing…" : "Remove from this device…"} - - - - - )} -
-
-
+ + onRemove(environment)}> + {isRemoving ? "Removing…" : "Remove from this device…"} + + + + ); } @@ -1817,19 +1794,6 @@ export function ConnectionsSettings() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); - const [handledSearchTargetId, setHandledSearchTargetId] = useState(null); - if (primaryEnvironment && handledSearchTargetId !== searchTargetId) { - setHandledSearchTargetId(searchTargetId); - if (["connections-environment", "wsl-backend"].includes(searchTargetId)) { - setSelectedEnvironmentId(primaryEnvironment.environmentId); - } - } - const selectedEnvironment = - environments.find((environment) => environment.environmentId === selectedEnvironmentId) ?? - primaryEnvironment ?? - environments[0]; const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, @@ -1846,13 +1810,24 @@ export function ConnectionsSettings() { ? (primarySessionState.data.scopes ?? null) : null; const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); + // Catalog order is the order the machines were added; rows never jump when + // one is switched off. const savedEnvironments = useMemo( () => - environments - .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget") - .toSorted((left, right) => left.label.localeCompare(right.label)), + environments.filter( + (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", + ), [environments], ); + // The WSL backend is managed from the WSL row under this machine, so it has + // no row of its own in the list. + const listedEnvironments = useMemo( + () => + savedEnvironments.filter( + (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), + ), + [savedEnvironments], + ); // Machines "Update all" can reach: switched on, connected, behind the client // version, remotely updatable, and not already mid-update. The button only // renders when this list is non-empty. @@ -1901,10 +1876,15 @@ export function ConnectionsSettings() { [savedServerUpdateStates], ); // Switched-off machines never receive threads, so they stay out of the - // load balancing list. + // load balancing and GitHub sharing lists. The WSL backend has no row in + // the Environments list but does take threads, so it stays in here. This + // machine leads the list. const loadBalancingEnvironments = useMemo( - () => environments.filter((environment) => environment.entry.enabled), - [environments], + () => [ + ...(primaryEnvironment ? [primaryEnvironment] : []), + ...savedEnvironments.filter((environment) => environment.entry.enabled), + ], + [primaryEnvironment, savedEnvironments], ); const savedDesktopSshEnvironmentKeys = useMemo(() => { const keys = new Set(); @@ -3259,69 +3239,78 @@ export function ConnectionsSettings() { - {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( - - ) : primaryVersionMismatch ? ( - - - Update to match this client. - - } + icon={ + + } + headerAction={ + primaryEnvironmentId !== null ? ( + + - - {primaryVersionMismatch.serverVersion} {" "} - {primaryVersionMismatch.clientVersion} - - - ) : null - } - control={ - primaryVersionMismatch && - primaryEnvironmentId !== null && - primaryServerUpdateState.status !== "running" ? ( - + + + + - ) : undefined - } - /> - ) : null} - {primaryEnvironmentId !== null ? ( - + + ) : null + } + > + + ) : ( + [ + primaryServerConfig?.environment.serverVersion ?? null, + primaryEnvironment?.displayUrl ?? null, + ] + .filter((value): value is string => value !== null) + .join(" · ") || "Loading…" + ) + } + control={ + primaryVersionMismatch && + primaryEnvironmentId !== null && + primaryServerUpdateState.status !== "running" ? ( + - } - /> - ) : null} + ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( + Up to date + ) : undefined + } + /> {desktopBridge ? ( <> {renderNetworkAccessRow()} @@ -3339,10 +3328,14 @@ export function ConnectionsSettings() { {isLocalBackendRemotelyReachable ? ( - {renderAuthorizedClients("current")} - + ) : null} + {primarySettings} {savedServerUpdateTargets.length > 0 ? ( @@ -3725,118 +3718,22 @@ export function ConnectionsSettings() {
} > -
-
- -
- {(primaryEnvironment - ? [primaryEnvironment, ...savedEnvironments] - : savedEnvironments - ).map((environment) => { - const selected = environment.environmentId === selectedEnvironment?.environmentId; - const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; - return ( - - ); - })} -
-
-
- - {selectedEnvironment ? ( -
- {selectedEnvironment.entry.target._tag === "PrimaryConnectionTarget" ? ( - primarySettings - ) : ( - - - - )} - {selectedEnvironment.entry.enabled && loadBalancingEnvironments.length > 1 ? ( - - - - ) : null} - -
- ) : null} -
-
+ {listedEnvironments.map((environment) => ( + + ))} + ); } diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx index f990aa5a24417b6f6abea65e954b11bc69c0c882..b3b33fab02525bd512b043b9c188f372a42aae37 100644 --- a/apps/web/src/components/settings/EnvironmentIconPicker.tsx +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -5,7 +5,6 @@ import { type EnvironmentId, type ServerConfig, } from "@t3tools/contracts"; -import { useCallback } from "react"; import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; @@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useEnvironmentSessionState } from "../../state/session"; import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + MenuItem, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, +} from "../ui/menu"; import { resolvePrimaryOperateAccess, resolveRemoteOperateAccess, } from "./ProviderSettingsPanel.logic"; -const AUTOMATIC_VALUE = "automatic"; - /** * Why the picker is inert, in the order the user can do something about it. * Null means it can be changed. @@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) { } /** - * Picks the machine glyph an environment wears everywhere it is listed. - * "Automatic" clears the override so the server's own detection shows - * through; the label says what that currently resolves to so the user can - * tell whether detection got it right before overriding. The control stays - * visible while locked so the current icon still reads, the same way - * server-scoped rows go inert instead of disappearing. + * "Icon" submenu for an environment's row menu. Lists the machine kinds with + * the server's own detection marked, so the user can tell whether detection + * got it right before overriding. Picking the detected kind clears the + * override. Locked environments show the reason as a disabled item instead of + * hiding the submenu, so the current icon still reads. */ -export function EnvironmentIconPicker({ +export function EnvironmentIconMenu({ environmentId, serverConfig, - size = "sm", }: { readonly environmentId: EnvironmentId; readonly serverConfig: ServerConfig | null; - readonly size?: "xs" | "sm"; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const operateAccess = useEnvironmentOperateAccess(environmentId); const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); - const override = serverConfig?.settings.environmentIcon ?? null; - const detected = serverConfig?.environment.platform.machine ?? null; + // With no detection the server falls back to "server", so picking that + // kind clears the override the same way picking the detected kind does. + const detected = serverConfig?.environment.platform.machine ?? "server"; const resolved = resolveEnvironmentMachineKind(serverConfig); - const value = override ?? AUTOMATIC_VALUE; - const automaticLabel = - detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`; - const handleValueChange = useCallback( - (next: string | null) => { - if (next === null) return; - if (next === AUTOMATIC_VALUE) { - updateSettings({ environmentIcon: null }); - } else if (isEnvironmentMachineKind(next)) { - updateSettings({ environmentIcon: next }); - } - }, - [updateSettings], - ); - - const select = ( - - ); - - if (lock === null) { - return select; - } return ( - - - } - > - {select} - - - {lock} - - + + + + Icon + + + {lock !== null ? ( + <> + + {lock} + + + + ) : null} + { + if (lock !== null || !isEnvironmentMachineKind(next)) return; + updateSettings({ environmentIcon: next === detected ? null : next }); + }} + > + {ENVIRONMENT_MACHINE_KINDS.map((kind) => ( + + + + + {ENVIRONMENT_MACHINE_KIND_LABELS[kind]} + + {kind === detected ? ( + + {serverConfig?.environment.platform.machine ? "detected" : "default"} + + ) : null} + + + ))} + + + ); } diff --git a/apps/web/src/components/settings/EnvironmentRow.tsx b/apps/web/src/components/settings/EnvironmentRow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f3dae7e47c133d40a4e3176b57cf0431b6f4319 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentRow.tsx @@ -0,0 +1,74 @@ +import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import type { EnvironmentPresentation } from "~/state/environments"; +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; + +export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { + const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; + return target.port ? `${authority}:${target.port}` : authority; +} + +/** + * How this client reaches a machine, printed first in every environment row so + * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend. + */ +export function environmentTransportLabel(environment: EnvironmentPresentation): string { + const { entry } = environment; + if (entry.target._tag === "PrimaryConnectionTarget") return "This machine"; + if (environment.relayManaged) return "T3 Connect"; + if (isDesktopLocalConnectionTarget(entry.target)) return "WSL"; + if ( + entry.target._tag === "SshConnectionTarget" && + Option.isSome(entry.profile) && + entry.profile.value._tag === "SshConnectionProfile" + ) { + return `SSH ${formatDesktopSshTarget(entry.profile.value.target)}`; + } + return environment.displayUrl ?? "Remote link"; +} + +/** + * One machine in a grouped settings list: icon, name, a single subtitle line, + * and controls on the right. Every environment list on the Connections page + * uses this so the lists share one rhythm. + */ +export function EnvironmentRow({ + kind, + label, + subtitle, + below, + dimmed = false, + className, + children, +}: { + readonly kind: EnvironmentMachineKind; + readonly label: string; + readonly subtitle: ReactNode; + /** Extra content under the subtitle, such as update progress. */ + readonly below?: ReactNode; + readonly dimmed?: boolean; + readonly className?: string; + readonly children?: ReactNode; +}) { + return ( +
+ +
+

{label}

+
{subtitle}
+ {below} +
+
{children}
+
+ ); +} diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..109e97b9d8e8ce2837879d9dd14500ed9d9a180f --- /dev/null +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -0,0 +1,67 @@ +import { ChevronRightIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { useSettingsSearchTarget, useSettingsSearchTargetId } from "./settingsLayout"; + +/** + * A grouped settings section that starts closed. The header carries the title, + * a one line summary of what is set inside, and an optional control such as + * the section's own switch. A settings search that targets the section opens it. + */ +export function FoldedSettingsSection({ + id, + title, + summary, + control, + children, +}: { + readonly id: string; + readonly title: string; + readonly summary?: string | null; + readonly control?: ReactNode; + readonly children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); + const targetRef = useSettingsSearchTarget(id); + // A search jump lands inside the fold, so open it before the scroll runs. + const [openedForTarget, setOpenedForTarget] = useState(null); + if (searchTargetId === id && openedForTarget !== id) { + setOpenedForTarget(id); + if (!open) setOpen(true); + } + + return ( +
+ +
+ + + {title} + {summary ? ( + {summary} + ) : null} + + {control ?
{control}
: null} +
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.test.ts b/apps/web/src/components/settings/GitHubRoutingSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f09ab69c99dd8dd9e15bf15a8a35e0829a77ab2 --- /dev/null +++ b/apps/web/src/components/settings/GitHubRoutingSettings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { summarizeGitHubRouting } from "./GitHubRoutingSettings"; + +describe("summarizeGitHubRouting", () => { + it("is empty when no machine shares", () => { + expect(summarizeGitHubRouting([{ label: "alvin", permission: "off" }])).toBeNull(); + }); + + it("groups sharing machines by permission, read and act first", () => { + expect( + summarizeGitHubRouting([ + { label: "alvin", permission: "read" }, + { label: "bb-1", permission: "read-write" }, + { label: "cup2", permission: "off" }, + { label: "Theo's MacBook Pro", permission: "read-write" }, + ]), + ).toBe("bb-1, Theo's MacBook Pro read and act · alvin read PRs"); + }); +}); diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.tsx b/apps/web/src/components/settings/GitHubRoutingSettings.tsx index fad58b4f644b1e3eb7587697d9123a8649cbcc88..10bb2dd98e354ed1ad00875e22565039b1018538 100644 --- a/apps/web/src/components/settings/GitHubRoutingSettings.tsx +++ b/apps/web/src/components/settings/GitHubRoutingSettings.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { gitHubRoutingConnectionKey, gitHubRoutingPermissionFor, @@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { toastManager } from "../ui/toast"; -import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { searchableSetting } from "./settingsSearch"; const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [ @@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> { value: "read-write", label: "Read and act" }, ]; +const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const; + +/** + * Closed-header summary: the machines that share, grouped by permission. + * Null when nothing is shared. + */ +export function summarizeGitHubRouting( + entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>, +): string | null { + const groups = (["read-write", "read"] as const).flatMap((permission) => { + const labels = entries.filter((entry) => entry.permission === permission); + return labels.length === 0 + ? [] + : [`${labels.map((entry) => entry.label).join(", ")} ${summaryLabels[permission]}`]; + }); + return groups.length === 0 ? null : groups.join(" · "); +} + +/** + * Folded section under the environments list. One row per switched-on machine + * with how much of its GitHub access the other machines may use. The trust + * warning is the first line of the body so it sits next to the control. + * Rendered only when two or more machines are on. + */ export function GitHubRoutingSettings({ environments, - selectedEnvironmentId, }: { readonly environments: ReadonlyArray; - readonly selectedEnvironmentId: EnvironmentId; }) { const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); const [saving, setSaving] = useState(false); - const selectedEnvironments = environments.filter( - (environment) => environment.environmentId === selectedEnvironmentId, - ); + if (environments.length < 2) return null; + + const { id, title } = searchableSetting("github-routing"); return ( - - - {selectedEnvironments.map((environment) => ( - ({ + label: environment.label, + permission: gitHubRoutingPermissionFor(environment.entry, permissions), + })), + ) ?? "Off" + } + > +

+ Machines you trust here can read PR data through each other's GitHub access. Enable both + machines. Read and act may use broader permissions than the machine that owns them. This + applies only to this device. +

+ {environments.map((environment) => ( + { - if (permission === null) return; - setSaving(true); - void update({ environmentId: environment.environmentId, permission }).then( - (result) => { - setSaving(false); - if (result._tag === "Failure") - toastManager.add({ - type: "error", - title: "Could not save GitHub routing permission", - }); - }, - ); - }} + kind={resolveEnvironmentMachineKind(environment.serverConfig)} + label={environment.label} + subtitle={environmentTransportLabel(environment)} + > + - } - /> + + + + {options.map(({ value, label }) => ( + + {label} + + ))} + + + ))} -
+ ); } diff --git a/apps/web/src/components/settings/LoadBalancingSettings.test.ts b/apps/web/src/components/settings/LoadBalancingSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..53e3c16d6f8d8999a53cbedcd7aea3867d2d0617 --- /dev/null +++ b/apps/web/src/components/settings/LoadBalancingSettings.test.ts @@ -0,0 +1,32 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { loadPreferenceForWeight, summarizeLoadPreferences } from "./LoadBalancingSettings"; + +const machines = [ + { environmentId: EnvironmentId.make("a"), label: "alvin" }, + { environmentId: EnvironmentId.make("b"), label: "bb-1" }, + { environmentId: EnvironmentId.make("c"), label: "ProMini" }, +]; + +describe("loadPreferenceForWeight", () => { + it("snaps legacy slider weights onto the four preferences", () => { + expect(loadPreferenceForWeight(undefined)).toBe(50); + expect(loadPreferenceForWeight(0)).toBe(0); + expect(loadPreferenceForWeight(10)).toBe(25); + expect(loadPreferenceForWeight(50)).toBe(50); + expect(loadPreferenceForWeight(80)).toBe(100); + }); +}); + +describe("summarizeLoadPreferences", () => { + it("is empty when every machine is at Normal", () => { + expect(summarizeLoadPreferences(machines, { a: 50 })).toBeNull(); + }); + + it("lists only the machines that differ from Normal, in list order", () => { + expect(summarizeLoadPreferences(machines, { b: 100, c: 0 })).toBe( + "bb-1 prefer · ProMini manual only", + ); + }); +}); diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx index ae42139f58ded105e0481a7bd109a552f55ab0fa..1784e373210596ccd71071c44a685cd9e14cf9d5 100644 --- a/apps/web/src/components/settings/LoadBalancingSettings.tsx +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -1,3 +1,5 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; + import { useClientSettings, useClientSettingsHydrated, @@ -6,7 +8,8 @@ import { import type { EnvironmentPresentation } from "~/state/environments"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; -import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { searchableSetting } from "./settingsSearch"; const preferences = [ @@ -14,8 +17,44 @@ const preferences = [ { value: 50, label: "Normal" }, { value: 25, label: "Less often" }, { value: 0, label: "Manual only" }, -]; +] as const; +type LoadPreference = (typeof preferences)[number]["value"]; + +/** Snaps a saved weight (older builds stored a slider value) onto the four preferences. */ +export function loadPreferenceForWeight(weight: number | undefined): LoadPreference { + if (weight === undefined || weight === 50) return 50; + if (weight === 0) return 0; + return weight < 50 ? 25 : 100; +} + +function preferenceLabel(preference: LoadPreference): string { + return preferences.find((entry) => entry.value === preference)!.label; +} + +/** + * Closed-header summary: the machines not at Normal, so the folded section + * still tells you what is set. Null when every machine is at the default. + */ +export function summarizeLoadPreferences( + environments: ReadonlyArray>, + weights: Readonly>, +): string | null { + const parts = environments.flatMap((environment) => { + const preference = loadPreferenceForWeight(weights[environment.environmentId]); + return preference === 50 + ? [] + : [`${environment.label} ${preferenceLabel(preference).toLowerCase()}`]; + }); + return parts.length === 0 ? null : parts.join(" · "); +} + +/** + * Folded section under the environments list. Its switch turns balancing on + * for this client, and the body holds one row per switched-on machine with + * how often that machine should receive new threads. Rendered only when two + * or more machines are on, since one machine has nothing to balance against. + */ export function LoadBalancingSettings({ environments, }: { @@ -25,78 +64,71 @@ export function LoadBalancingSettings({ const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdateClientSettings(); - if (environments.length < 2) { - return ( - -

- Connect another machine to automatically balance load across environments. -

-
- ); - } + if (environments.length < 2) return null; + const { id, title } = searchableSetting("load-balancing"); return ( - - updateSettings({ loadBalancingEnabled })} - /> - } - /> - - ); -} - -export function LoadBalancingPreference({ environment }: { environment: EnvironmentPresentation }) { - const settings = useClientSettings(); - const settingsHydrated = useClientSettingsHydrated(); - const updateSettings = useUpdateClientSettings(); - const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; - // Keep saved slider weights until the user chooses a different preference. - const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; - - return ( - { - if (value !== null) { + updateSettings({ loadBalancingEnabled })} + /> + } + > +

+ New threads in shared projects start on the machine with the most free CPU and memory, + weighted by each machine's preference. +

+ {environments.map((environment) => ( + + - } - /> + + + + + {preferences.map(({ value, label }) => ( + + {label} + + ))} + + + + ))} + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 373ab7885c428e429b4cf65416017212d745b195..e6961badcfc537f090aac2e188e2f8092a4da273 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -682,7 +682,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "connections-environment", - title: "This environment", + title: "This machine", to: "/settings/connections", searchTerms: [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", @@ -690,7 +690,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "remote-environments", - title: "Remote environments", + title: "Environments", to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, @@ -704,7 +704,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "github-routing", - title: "GitHub routing", + title: "GitHub sharing", to: "/settings/connections", searchTerms: ["pull request trusted environments shared credentials permissions read actions"], }, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223d974c9ced82252dd88a139872b6f..55514fc83ff4e7a643b375c5879ce920a403930c 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -65,7 +65,8 @@ another link to share. Auto balance is off by default. On web and desktop, enable it in **Settings → Connections → Load balancing** to automatically choose a machine for -new threads in projects grouped across connected environments. +new threads in projects grouped across connected environments. The section +appears once two or more machines are switched on. Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and memory available, **Less often** to reduce its share, or **Manual only** to exclude it from automatic selection. These are preferences, not fixed traffic percentages. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d95444b4ae053b71e77159b163ee734f01a6543b..64cdc19c2812a90cafc330517485ee7cc3a03fe9 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -99,7 +99,7 @@ GitLab calls these merge requests. GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also supports approving waiting fork workflows and opening a revert pull request for a merged change. -GitHub routing is off by default. In Settings → Connections (Environments on mobile), choose +GitHub sharing is off by default. In Settings → Connections → GitHub sharing (Environments on mobile), choose **Read PRs** or **Read and act** for each environment you trust to share GitHub access. Enable both the original environment and the environment answering its requests on this client. **Read and act** can use broader GitHub permissions than the original environment's credential;