| author | |
| committer | |
| log | 5e961d3d7fc07ff1ba998bfd20bb366c2ab8c9a9 |
| tree | d4d73b0361d657d22bf4aa54ca9e1bd2e55a6666 |
| parent | 2d737465027b18cafd0c4704e9025b124bc94b2c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>13 files changed, 720 insertions(+), 586 deletions(-)
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+3-3| ... | @@ -175,7 +175,7 @@ describe("cloud onboarding discovery", () => { | ... | @@ -175,7 +175,7 @@ describe("cloud onboarding discovery", () => { |
| 175 | finishDiscovery(linkedMachines); | 175 | finishDiscovery(linkedMachines); |
| 176 | }); | 176 | }); |
| 177 | expect(onDiscoveryReady).toHaveBeenCalledTimes(1); | 177 | expect(onDiscoveryReady).toHaveBeenCalledTimes(1); |
| 178 | expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); | 178 | expect(renderer!.root.findByType("button").children).toEqual(["Add"]); |
| 179 | }); | 179 | }); |
| 180 | 180 | ||
| 181 | it("connects and selects discovered computers by default without overwriting deselection", async () => { | 181 | it("connects and selects discovered computers by default without overwriting deselection", async () => { |
| ... | @@ -232,7 +232,7 @@ describe("cloud onboarding discovery", () => { | ... | @@ -232,7 +232,7 @@ describe("cloud onboarding discovery", () => { |
| 232 | expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ | 232 | expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ |
| 233 | "Work laptop", | 233 | "Work laptop", |
| 234 | ]); | 234 | ]); |
| 235 | expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); | 235 | expect(renderer!.root.findByType("button").children).toEqual(["Add"]); |
| 236 | await advance(30_000); | 236 | await advance(30_000); |
| 237 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); | 237 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); |
| 238 | }); | 238 | }); |
| ... | @@ -240,7 +240,7 @@ describe("cloud onboarding discovery", () => { | ... | @@ -240,7 +240,7 @@ describe("cloud onboarding discovery", () => { |
| 240 | it("keeps a discovered computer visible when it is added to the browser", async () => { | 240 | it("keeps a discovered computer visible when it is added to the browser", async () => { |
| 241 | discovery.listEnvironments.mockResolvedValue(linkedMachines); | 241 | discovery.listEnvironments.mockResolvedValue(linkedMachines); |
| 242 | await mount(); | 242 | await mount(); |
| 243 | expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); | 243 | expect(renderer!.root.findByType("button").children).toEqual(["Add"]); |
| 244 | await act(async () => { | 244 | await act(async () => { |
| 245 | renderer!.update( | 245 | renderer!.update( |
| 246 | <CloudEnvironmentConnectRows | 246 | <CloudEnvironmentConnectRows |
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+6-5| ... | @@ -288,12 +288,13 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -288,12 +288,13 @@ export function CloudEnvironmentConnectRows({ |
| 288 | const statusText = savedConnection | 288 | const statusText = savedConnection |
| 289 | ? savedConnection.statusText | 289 | ? savedConnection.statusText |
| 290 | : availability === "online" | 290 | : availability === "online" |
| 291 | ? "Available · Relay online" | 291 | ? "T3 Connect · Not added · Relay online" |
| 292 | : availability === "offline" | 292 | : availability === "offline" |
| 293 | ? "Available · Relay offline" | 293 | ? "T3 Connect · Not added · Relay offline" |
| 294 | : availability === "checking" | 294 | : availability === "checking" |
| 295 | ? "Available · Checking relay status…" | 295 | ? "T3 Connect · Not added · Checking relay status…" |
| 296 | : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable"); | 296 | : (Option.getOrNull(error)?.message ?? |
| 297 | "T3 Connect · Not added · Relay status unavailable"); | ||
| 297 | if (selection) { | 298 | if (selection) { |
| 298 | return ( | 299 | return ( |
| 299 | <label | 300 | <label |
| ... | @@ -386,7 +387,7 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -386,7 +387,7 @@ export function CloudEnvironmentConnectRows({ |
| 386 | disabled={connectingEnvironmentIds.size > 0} | 387 | disabled={connectingEnvironmentIds.size > 0} |
| 387 | onClick={() => void connectEnvironment(environment)} | 388 | onClick={() => void connectEnvironment(environment)} |
| 388 | > | 389 | > |
| 389 | {connectingEnvironmentIds.has(environment.environmentId) ? "Connecting…" : "Connect"} | 390 | {connectingEnvironmentIds.has(environment.environmentId) ? "Adding…" : "Add"} |
| 390 | </Button> | 391 | </Button> |
| 391 | )} | 392 | )} |
| 392 | </div> | 393 | </div> |
apps/web/src/components/settings/ConnectionsSettings.tsx+268-371| ... | @@ -6,7 +6,6 @@ import { | ... | @@ -6,7 +6,6 @@ import { |
| 6 | TerminalIcon, | 6 | TerminalIcon, |
| 7 | } from "lucide-react"; | 7 | } from "lucide-react"; |
| 8 | import { useAtomValue } from "@effect/atom-react"; | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { useLocation } from "@tanstack/react-router"; | ||
| 10 | import { Atom } from "effect/unstable/reactivity"; | 9 | import { Atom } from "effect/unstable/reactivity"; |
| 11 | import { | 10 | import { |
| 12 | type KeyboardEvent, | 11 | type KeyboardEvent, |
| ... | @@ -42,7 +41,7 @@ import { | ... | @@ -42,7 +41,7 @@ import { |
| 42 | type EnvironmentId, | 41 | type EnvironmentId, |
| 43 | resolveEnvironmentMachineKind, | 42 | resolveEnvironmentMachineKind, |
| 44 | } from "@t3tools/contracts"; | 43 | } from "@t3tools/contracts"; |
| 45 | import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; | 44 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; |
| 46 | import { | 45 | import { |
| 47 | isAtomCommandInterrupted, | 46 | isAtomCommandInterrupted, |
| 48 | squashAtomCommandFailure, | 47 | squashAtomCommandFailure, |
| ... | @@ -67,8 +66,14 @@ import { | ... | @@ -67,8 +66,14 @@ import { |
| 67 | useRelativeTimeTick, | 66 | useRelativeTimeTick, |
| 68 | } from "./settingsLayout"; | 67 | } from "./settingsLayout"; |
| 69 | import { searchableSetting } from "./settingsSearch"; | 68 | import { searchableSetting } from "./settingsSearch"; |
| 70 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; | 69 | import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; |
| 71 | import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings"; | 70 | import { |
| 71 | EnvironmentRow, | ||
| 72 | environmentTransportLabel, | ||
| 73 | formatDesktopSshTarget, | ||
| 74 | } from "./EnvironmentRow"; | ||
| 75 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; | ||
| 76 | import { LoadBalancingSettings } from "./LoadBalancingSettings"; | ||
| 72 | import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; | 77 | import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; |
| 73 | import { Input } from "../ui/input"; | 78 | import { Input } from "../ui/input"; |
| 74 | import { CommandShortcut } from "../ui/command"; | 79 | import { CommandShortcut } from "../ui/command"; |
| ... | @@ -106,7 +111,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ... | @@ -106,7 +111,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 106 | import { QRCodeSvg } from "../ui/qr-code"; | 111 | import { QRCodeSvg } from "../ui/qr-code"; |
| 107 | import { Spinner } from "../ui/spinner"; | 112 | import { Spinner } from "../ui/spinner"; |
| 108 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 113 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 109 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | 114 | import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; |
| 110 | import { Switch } from "../ui/switch"; | 115 | import { Switch } from "../ui/switch"; |
| 111 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 116 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 112 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 117 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -158,7 +163,7 @@ import { | ... | @@ -158,7 +163,7 @@ import { |
| 158 | import { requestConfirmDialog } from "~/confirmDialog"; | 163 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 159 | import { useAtomCommand } from "../../state/use-atom-command"; | 164 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 160 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; | 165 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; |
| 161 | import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot"; | 166 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 162 | import { | 167 | import { |
| 163 | ServerUpdateAction, | 168 | ServerUpdateAction, |
| 164 | ServerUpdateProgress, | 169 | ServerUpdateProgress, |
| ... | @@ -288,11 +293,6 @@ function AccessScopeSummary({ | ... | @@ -288,11 +293,6 @@ function AccessScopeSummary({ |
| 288 | ); | 293 | ); |
| 289 | } | 294 | } |
| 290 | 295 | ||
| 291 | function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { | ||
| 292 | const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; | ||
| 293 | return target.port ? `${authority}:${target.port}` : authority; | ||
| 294 | } | ||
| 295 | |||
| 296 | function parseManualDesktopSshTarget(input: { | 296 | function parseManualDesktopSshTarget(input: { |
| 297 | readonly host: string; | 297 | readonly host: string; |
| 298 | readonly username: string; | 298 | readonly username: string; |
| ... | @@ -436,6 +436,20 @@ function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) | ... | @@ -436,6 +436,20 @@ function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) |
| 436 | ); | 436 | ); |
| 437 | } | 437 | } |
| 438 | 438 | ||
| 439 | /** Closed-header summary for the Authorized clients fold. */ | ||
| 440 | function summarizeAuthorizedClients( | ||
| 441 | sessions: ReadonlyArray<ServerClientSessionRecord>, | ||
| 442 | links: ReadonlyArray<ServerPairingLinkRecord>, | ||
| 443 | ): string { | ||
| 444 | const parts = [ | ||
| 445 | `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`, | ||
| 446 | links.length > 0 | ||
| 447 | ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}` | ||
| 448 | : null, | ||
| 449 | ]; | ||
| 450 | return parts.filter((part): part is string => part !== null).join(" · "); | ||
| 451 | } | ||
| 452 | |||
| 439 | function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) { | 453 | function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) { |
| 440 | return [...sessions].toSorted((left, right) => { | 454 | return [...sessions].toSorted((left, right) => { |
| 441 | if (left.current !== right.current) { | 455 | if (left.current !== right.current) { |
| ... | @@ -1419,6 +1433,44 @@ type SavedBackendListRowProps = { | ... | @@ -1419,6 +1433,44 @@ type SavedBackendListRowProps = { |
| 1419 | onRemove: (environment: EnvironmentPresentation) => void; | 1433 | onRemove: (environment: EnvironmentPresentation) => void; |
| 1420 | }; | 1434 | }; |
| 1421 | 1435 | ||
| 1436 | /** | ||
| 1437 | * Status word for a row subtitle: "Reconnecting: <reason>" instead of the | ||
| 1438 | * long-form sentence, since the row has one line and the full text is one | ||
| 1439 | * hover away. | ||
| 1440 | */ | ||
| 1441 | function savedBackendStatus(environment: EnvironmentPresentation): { | ||
| 1442 | readonly text: string; | ||
| 1443 | readonly tone: "muted" | "error"; | ||
| 1444 | } { | ||
| 1445 | if (!environment.entry.enabled) return { text: "Off", tone: "muted" }; | ||
| 1446 | const { connection } = environment; | ||
| 1447 | switch (connection.phase) { | ||
| 1448 | case "connected": | ||
| 1449 | return { text: "Connected", tone: "muted" }; | ||
| 1450 | case "connecting": | ||
| 1451 | return { text: "Connecting", tone: "muted" }; | ||
| 1452 | case "reconnecting": | ||
| 1453 | return { | ||
| 1454 | text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", | ||
| 1455 | tone: "error", | ||
| 1456 | }; | ||
| 1457 | case "error": | ||
| 1458 | return { | ||
| 1459 | text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed", | ||
| 1460 | tone: "error", | ||
| 1461 | }; | ||
| 1462 | case "offline": | ||
| 1463 | return { text: "Offline", tone: "muted" }; | ||
| 1464 | case "available": | ||
| 1465 | return { text: "Not connected", tone: "muted" }; | ||
| 1466 | } | ||
| 1467 | } | ||
| 1468 | |||
| 1469 | /** | ||
| 1470 | * One added machine in the Environments list. The switch is the main action; | ||
| 1471 | * the update icon appears only when that machine can take an update; the | ||
| 1472 | * row menu holds the icon override, trace ID, and removal. | ||
| 1473 | */ | ||
| 1422 | function SavedBackendListRow({ | 1474 | function SavedBackendListRow({ |
| 1423 | environment, | 1475 | environment, |
| 1424 | removingEnvironmentId, | 1476 | removingEnvironmentId, |
| ... | @@ -1427,19 +1479,8 @@ function SavedBackendListRow({ | ... | @@ -1427,19 +1479,8 @@ function SavedBackendListRow({ |
| 1427 | }: SavedBackendListRowProps) { | 1479 | }: SavedBackendListRowProps) { |
| 1428 | const environmentId = environment.environmentId; | 1480 | const environmentId = environment.environmentId; |
| 1429 | const enabled = environment.entry.enabled; | 1481 | const enabled = environment.entry.enabled; |
| 1430 | const connectionState = environment.connection.phase; | 1482 | const isConnected = environment.connection.phase === "connected"; |
| 1431 | const isConnected = connectionState === "connected"; | ||
| 1432 | const isRemoving = removingEnvironmentId === environmentId; | 1483 | const isRemoving = removingEnvironmentId === environmentId; |
| 1433 | const stateDotClassName = !enabled | ||
| 1434 | ? "bg-muted-foreground/40" | ||
| 1435 | : connectionState === "connected" | ||
| 1436 | ? "bg-success" | ||
| 1437 | : connectionState === "connecting" || connectionState === "reconnecting" | ||
| 1438 | ? "bg-warning" | ||
| 1439 | : connectionState === "error" | ||
| 1440 | ? "bg-destructive" | ||
| 1441 | : "bg-muted-foreground/40"; | ||
| 1442 | const statusTooltip = enabled ? connectionStatusText(environment.connection) : "Off"; | ||
| 1443 | const errorTraceId = environment.connection.traceId; | 1484 | const errorTraceId = environment.connection.traceId; |
| 1444 | const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ | 1485 | const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ |
| 1445 | target: "trace ID", | 1486 | target: "trace ID", |
| ... | @@ -1470,25 +1511,18 @@ function SavedBackendListRow({ | ... | @@ -1470,25 +1511,18 @@ function SavedBackendListRow({ |
| 1470 | const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); | 1511 | const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); |
| 1471 | const resumingServerUpdate = | 1512 | const resumingServerUpdate = |
| 1472 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; | 1513 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; |
| 1473 | const sshTarget = | 1514 | const status = savedBackendStatus(environment); |
| 1474 | environment.entry.target._tag === "SshConnectionTarget" && | 1515 | const serverVersion = environment.serverConfig?.environment.serverVersion ?? null; |
| 1475 | Option.isSome(environment.entry.profile) && | 1516 | const subtitleText = [ |
| 1476 | environment.entry.profile.value._tag === "SshConnectionProfile" | 1517 | environmentTransportLabel(environment), |
| 1477 | ? environment.entry.profile.value.target | 1518 | resumingServerUpdate ? "Restarting" : status.text, |
| 1478 | : null; | 1519 | enabled && versionMismatch ? serverVersion : null, |
| 1479 | const metadataBits = [ | 1520 | ] |
| 1480 | sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, | 1521 | .filter((value): value is string => value !== null) |
| 1481 | environment.relayManaged ? "T3 Connect" : null, | 1522 | .join(" · "); |
| 1482 | enabled ? null : "Off", | ||
| 1483 | ].filter((value): value is string => value !== null); | ||
| 1484 | 1523 | ||
| 1485 | // The WSL backend is a desktop-managed local backend (it surfaces as a bearer | ||
| 1486 | // environment whose connection id is prefixed "local:"), not a remote | ||
| 1487 | // environment you connect to or remove here — its lifecycle is driven by the | ||
| 1488 | // WSL on/off + distro picker on this page. | ||
| 1489 | const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target); | ||
| 1490 | // Only a connected, enabled machine can take a remote update; a switched-off | 1524 | // Only a connected, enabled machine can take a remote update; a switched-off |
| 1491 | // one keeps the "update available" note so the icon is not a surprise later. | 1525 | // one keeps the version note so the icon is not a surprise later. |
| 1492 | const showUpdateAction = | 1526 | const showUpdateAction = |
| 1493 | enabled && | 1527 | enabled && |
| 1494 | isConnected && | 1528 | isConnected && |
| ... | @@ -1496,153 +1530,96 @@ function SavedBackendListRow({ | ... | @@ -1496,153 +1530,96 @@ function SavedBackendListRow({ |
| 1496 | (serverUpdateState.status === "idle" || serverUpdateState.status === "failed"); | 1530 | (serverUpdateState.status === "idle" || serverUpdateState.status === "failed"); |
| 1497 | 1531 | ||
| 1498 | return ( | 1532 | return ( |
| 1499 | <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}> | 1533 | <EnvironmentRow |
| 1500 | <div className={ITEM_ROW_INNER_CLASSNAME}> | 1534 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} |
| 1501 | <div className="min-w-0 flex-1 space-y-1"> | 1535 | label={environment.label} |
| 1502 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | 1536 | dimmed={!enabled} |
| 1503 | <div className="flex min-h-5 min-w-0 max-w-full items-center gap-1.5"> | 1537 | subtitle={ |
| 1504 | <ConnectionStatusDot | 1538 | <Tooltip> |
| 1505 | tooltipText={statusTooltip} | 1539 | <TooltipTrigger |
| 1506 | dotClassName={stateDotClassName} | 1540 | render={ |
| 1507 | pingClassName={ | 1541 | <span |
| 1508 | enabled && | 1542 | className={cn( |
| 1509 | (connectionState === "connecting" || connectionState === "reconnecting") | 1543 | "block truncate", |
| 1510 | ? "bg-warning/60 duration-2000" | 1544 | enabled && status.tone === "error" && !resumingServerUpdate && "text-destructive", |
| 1511 | : null | 1545 | )} |
| 1512 | } | ||
| 1513 | /> | ||
| 1514 | <EnvironmentMachineIcon | ||
| 1515 | aria-hidden | ||
| 1516 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | ||
| 1517 | className="size-3.5 shrink-0 text-muted-foreground" | ||
| 1518 | /> | 1546 | /> |
| 1519 | <h3 className="min-w-0 truncate text-sm font-medium text-foreground"> | 1547 | } |
| 1520 | {environment.label} | 1548 | > |
| 1521 | </h3> | 1549 | {subtitleText} |
| 1522 | </div> | 1550 | </TooltipTrigger> |
| 1523 | {isConnected ? ( | 1551 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> |
| 1524 | <div className="w-full sm:w-auto"> | 1552 | {enabled ? connectionStatusText(environment.connection) : "Switched off"} |
| 1525 | <EnvironmentIconPicker | 1553 | {versionMismatch |
| 1526 | environmentId={environmentId} | 1554 | ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` |
| 1527 | serverConfig={environment.serverConfig} | 1555 | : ""} |
| 1528 | size="xs" | 1556 | </TooltipPopup> |
| 1529 | /> | 1557 | </Tooltip> |
| 1530 | </div> | 1558 | } |
| 1531 | ) : null} | 1559 | below={ |
| 1560 | serverUpdateState.status !== "idle" ? ( | ||
| 1561 | <div className="mt-1 max-w-md"> | ||
| 1562 | <ServerUpdateProgress state={serverUpdateState} /> | ||
| 1532 | </div> | 1563 | </div> |
| 1533 | {metadataBits.length > 0 ? ( | 1564 | ) : null |
| 1534 | <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> | 1565 | } |
| 1535 | ) : null} | 1566 | > |
| 1536 | {serverUpdateState.status !== "idle" ? ( | 1567 | {showUpdateAction ? ( |
| 1537 | <div className="max-w-md"> | 1568 | <ServerUpdateAction |
| 1538 | <ServerUpdateProgress state={serverUpdateState} /> | 1569 | environmentId={environmentId} |
| 1539 | </div> | 1570 | serverLabel={`${environment.label} server`} |
| 1540 | ) : versionMismatch ? ( | 1571 | selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} |
| 1541 | <Tooltip> | 1572 | desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} |
| 1542 | <TooltipTrigger | 1573 | threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} |
| 1543 | render={ | 1574 | targetVersion={versionMismatch.clientVersion} |
| 1544 | <button | 1575 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update"} |
| 1545 | type="button" | 1576 | appearance="icon" |
| 1546 | className="w-fit cursor-help rounded-sm text-left text-muted-foreground text-xs" | 1577 | /> |
| 1547 | > | 1578 | ) : null} |
| 1548 | Server update available | 1579 | <Tooltip> |
| 1549 | </button> | 1580 | <TooltipTrigger |
| 1550 | } | 1581 | render={ |
| 1551 | /> | 1582 | <Switch |
| 1552 | <TooltipPopup side="top"> | 1583 | size="sm" |
| 1553 | {versionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "} | 1584 | checked={enabled} |
| 1554 | {versionMismatch.clientVersion} | 1585 | disabled={isRemoving} |
| 1555 | </TooltipPopup> | 1586 | aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`} |
| 1556 | </Tooltip> | 1587 | onCheckedChange={(checked) => onSetEnabled(environmentId, checked)} |
| 1557 | ) : null} | 1588 | /> |
| 1558 | {enabled && environment.connection.error && !resumingServerUpdate ? ( | 1589 | } |
| 1559 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> | 1590 | /> |
| 1560 | <span className="min-w-0 break-words"> | 1591 | <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup> |
| 1561 | {connectionStatusText(environment.connection)} | 1592 | </Tooltip> |
| 1562 | </span> | 1593 | <Menu> |
| 1563 | {errorTraceId ? ( | 1594 | <MenuTrigger |
| 1564 | <button | 1595 | render={ |
| 1565 | type="button" | 1596 | <Button |
| 1566 | className="shrink-0 underline underline-offset-2" | 1597 | type="button" |
| 1567 | onClick={() => copyTraceId(errorTraceId)} | 1598 | variant="ghost" |
| 1568 | > | 1599 | size="icon-xs" |
| 1569 | Copy trace ID | 1600 | className="text-muted-foreground hover:text-foreground" |
| 1570 | </button> | 1601 | disabled={isRemoving} |
| 1571 | ) : null} | 1602 | aria-label={`More actions for ${environment.label}`} |
| 1572 | </p> | ||
| 1573 | ) : null} | ||
| 1574 | </div> | ||
| 1575 | <div className="flex w-full shrink-0 items-center gap-1 sm:w-auto sm:justify-end"> | ||
| 1576 | {showUpdateAction ? ( | ||
| 1577 | <ServerUpdateAction | ||
| 1578 | environmentId={environmentId} | ||
| 1579 | serverLabel={`${environment.label} server`} | ||
| 1580 | selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} | ||
| 1581 | desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} | ||
| 1582 | threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} | ||
| 1583 | targetVersion={versionMismatch.clientVersion} | ||
| 1584 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update"} | ||
| 1585 | appearance="icon" | ||
| 1586 | /> | 1603 | /> |
| 1604 | } | ||
| 1605 | > | ||
| 1606 | <EllipsisIcon className="size-3.5" /> | ||
| 1607 | </MenuTrigger> | ||
| 1608 | <MenuPopup align="end" className="min-w-52"> | ||
| 1609 | <EnvironmentIconMenu | ||
| 1610 | environmentId={environmentId} | ||
| 1611 | serverConfig={environment.serverConfig} | ||
| 1612 | /> | ||
| 1613 | {errorTraceId ? ( | ||
| 1614 | <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem> | ||
| 1587 | ) : null} | 1615 | ) : null} |
| 1588 | {isWslEnvironment ? ( | 1616 | <MenuSeparator /> |
| 1589 | <Tooltip> | 1617 | <MenuItem variant="destructive" onClick={() => onRemove(environment)}> |
| 1590 | <TooltipTrigger | 1618 | {isRemoving ? "Removing…" : "Remove from this device…"} |
| 1591 | render={ | 1619 | </MenuItem> |
| 1592 | <Button size="xs" variant="outline" disabled> | 1620 | </MenuPopup> |
| 1593 | Managed locally | 1621 | </Menu> |
| 1594 | </Button> | 1622 | </EnvironmentRow> |
| 1595 | } | ||
| 1596 | /> | ||
| 1597 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> | ||
| 1598 | Select the primary environment to turn the WSL backend on or off. | ||
| 1599 | </TooltipPopup> | ||
| 1600 | </Tooltip> | ||
| 1601 | ) : ( | ||
| 1602 | <> | ||
| 1603 | <Tooltip> | ||
| 1604 | <TooltipTrigger | ||
| 1605 | render={ | ||
| 1606 | <Switch | ||
| 1607 | size="sm" | ||
| 1608 | checked={enabled} | ||
| 1609 | disabled={isRemoving} | ||
| 1610 | aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`} | ||
| 1611 | onCheckedChange={(checked) => onSetEnabled(environmentId, checked)} | ||
| 1612 | /> | ||
| 1613 | } | ||
| 1614 | /> | ||
| 1615 | <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup> | ||
| 1616 | </Tooltip> | ||
| 1617 | <Menu> | ||
| 1618 | <MenuTrigger | ||
| 1619 | render={ | ||
| 1620 | <Button | ||
| 1621 | type="button" | ||
| 1622 | variant="ghost" | ||
| 1623 | size="icon-xs" | ||
| 1624 | className="text-muted-foreground hover:text-foreground" | ||
| 1625 | disabled={isRemoving} | ||
| 1626 | aria-label={`More actions for ${environment.label}`} | ||
| 1627 | /> | ||
| 1628 | } | ||
| 1629 | > | ||
| 1630 | <EllipsisIcon className="size-3.5" /> | ||
| 1631 | </MenuTrigger> | ||
| 1632 | <MenuPopup align="end" className="min-w-52"> | ||
| 1633 | {errorTraceId ? ( | ||
| 1634 | <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem> | ||
| 1635 | ) : null} | ||
| 1636 | <MenuItem variant="destructive" onClick={() => onRemove(environment)}> | ||
| 1637 | {isRemoving ? "Removing…" : "Remove from this device…"} | ||
| 1638 | </MenuItem> | ||
| 1639 | </MenuPopup> | ||
| 1640 | </Menu> | ||
| 1641 | </> | ||
| 1642 | )} | ||
| 1643 | </div> | ||
| 1644 | </div> | ||
| 1645 | </div> | ||
| 1646 | ); | 1623 | ); |
| 1647 | } | 1624 | } |
| 1648 | 1625 | ||
| ... | @@ -1817,19 +1794,6 @@ export function ConnectionsSettings() { | ... | @@ -1817,19 +1794,6 @@ export function ConnectionsSettings() { |
| 1817 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 1794 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1818 | const { environments } = useEnvironments(); | 1795 | const { environments } = useEnvironments(); |
| 1819 | const primaryEnvironment = usePrimaryEnvironment(); | 1796 | const primaryEnvironment = usePrimaryEnvironment(); |
| 1820 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); | ||
| 1821 | const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); | ||
| 1822 | const [handledSearchTargetId, setHandledSearchTargetId] = useState<string | null>(null); | ||
| 1823 | if (primaryEnvironment && handledSearchTargetId !== searchTargetId) { | ||
| 1824 | setHandledSearchTargetId(searchTargetId); | ||
| 1825 | if (["connections-environment", "wsl-backend"].includes(searchTargetId)) { | ||
| 1826 | setSelectedEnvironmentId(primaryEnvironment.environmentId); | ||
| 1827 | } | ||
| 1828 | } | ||
| 1829 | const selectedEnvironment = | ||
| 1830 | environments.find((environment) => environment.environmentId === selectedEnvironmentId) ?? | ||
| 1831 | primaryEnvironment ?? | ||
| 1832 | environments[0]; | ||
| 1833 | const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); | 1797 | const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); |
| 1834 | const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { | 1798 | const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { |
| 1835 | reportFailure: false, | 1799 | reportFailure: false, |
| ... | @@ -1846,13 +1810,24 @@ export function ConnectionsSettings() { | ... | @@ -1846,13 +1810,24 @@ export function ConnectionsSettings() { |
| 1846 | ? (primarySessionState.data.scopes ?? null) | 1810 | ? (primarySessionState.data.scopes ?? null) |
| 1847 | : null; | 1811 | : null; |
| 1848 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); | 1812 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); |
| 1813 | // Catalog order is the order the machines were added; rows never jump when | ||
| 1814 | // one is switched off. | ||
| 1849 | const savedEnvironments = useMemo( | 1815 | const savedEnvironments = useMemo( |
| 1850 | () => | 1816 | () => |
| 1851 | environments | 1817 | environments.filter( |
| 1852 | .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget") | 1818 | (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", |
| 1853 | .toSorted((left, right) => left.label.localeCompare(right.label)), | 1819 | ), |
| 1854 | [environments], | 1820 | [environments], |
| 1855 | ); | 1821 | ); |
| 1822 | // The WSL backend is managed from the WSL row under this machine, so it has | ||
| 1823 | // no row of its own in the list. | ||
| 1824 | const listedEnvironments = useMemo( | ||
| 1825 | () => | ||
| 1826 | savedEnvironments.filter( | ||
| 1827 | (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), | ||
| 1828 | ), | ||
| 1829 | [savedEnvironments], | ||
| 1830 | ); | ||
| 1856 | // Machines "Update all" can reach: switched on, connected, behind the client | 1831 | // Machines "Update all" can reach: switched on, connected, behind the client |
| 1857 | // version, remotely updatable, and not already mid-update. The button only | 1832 | // version, remotely updatable, and not already mid-update. The button only |
| 1858 | // renders when this list is non-empty. | 1833 | // renders when this list is non-empty. |
| ... | @@ -1901,10 +1876,15 @@ export function ConnectionsSettings() { | ... | @@ -1901,10 +1876,15 @@ export function ConnectionsSettings() { |
| 1901 | [savedServerUpdateStates], | 1876 | [savedServerUpdateStates], |
| 1902 | ); | 1877 | ); |
| 1903 | // Switched-off machines never receive threads, so they stay out of the | 1878 | // Switched-off machines never receive threads, so they stay out of the |
| 1904 | // load balancing list. | 1879 | // load balancing and GitHub sharing lists. The WSL backend has no row in |
| 1880 | // the Environments list but does take threads, so it stays in here. This | ||
| 1881 | // machine leads the list. | ||
| 1905 | const loadBalancingEnvironments = useMemo( | 1882 | const loadBalancingEnvironments = useMemo( |
| 1906 | () => environments.filter((environment) => environment.entry.enabled), | 1883 | () => [ |
| 1907 | [environments], | 1884 | ...(primaryEnvironment ? [primaryEnvironment] : []), |
| 1885 | ...savedEnvironments.filter((environment) => environment.entry.enabled), | ||
| 1886 | ], | ||
| 1887 | [primaryEnvironment, savedEnvironments], | ||
| 1908 | ); | 1888 | ); |
| 1909 | const savedDesktopSshEnvironmentKeys = useMemo(() => { | 1889 | const savedDesktopSshEnvironmentKeys = useMemo(() => { |
| 1910 | const keys = new Set<string>(); | 1890 | const keys = new Set<string>(); |
| ... | @@ -3259,69 +3239,78 @@ export function ConnectionsSettings() { | ... | @@ -3259,69 +3239,78 @@ export function ConnectionsSettings() { |
| 3259 | <SettingsSection | 3239 | <SettingsSection |
| 3260 | {...searchableSetting("connections-environment")} | 3240 | {...searchableSetting("connections-environment")} |
| 3261 | title={primaryEnvironment?.label ?? "Primary environment"} | 3241 | title={primaryEnvironment?.label ?? "Primary environment"} |
| 3262 | > | 3242 | icon={ |
| 3263 | {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( | 3243 | <EnvironmentMachineIcon |
| 3264 | <SettingsRow | 3244 | aria-hidden |
| 3265 | title={ | 3245 | kind={resolveEnvironmentMachineKind(primaryServerConfig)} |
| 3266 | primaryServerUpdateState.status === "failed" | 3246 | className="size-4" |
| 3267 | ? "Update failed" | 3247 | /> |
| 3268 | : primaryServerUpdateState.status === "running" | 3248 | } |
| 3269 | ? "Updating server" | 3249 | headerAction={ |
| 3270 | : "Server update available" | 3250 | primaryEnvironmentId !== null ? ( |
| 3271 | } | 3251 | <Menu> |
| 3272 | description={ | 3252 | <MenuTrigger |
| 3273 | primaryServerUpdateState.status !== "idle" ? ( | 3253 | render={ |
| 3274 | <ServerUpdateProgress state={primaryServerUpdateState} /> | 3254 | <Button |
| 3275 | ) : primaryVersionMismatch ? ( | 3255 | type="button" |
| 3276 | <Tooltip> | 3256 | variant="ghost" |
| 3277 | <TooltipTrigger | 3257 | size="icon-xs" |
| 3278 | render={ | 3258 | className="text-muted-foreground hover:text-foreground" |
| 3279 | <button type="button" className="w-fit cursor-help rounded-sm text-left"> | 3259 | aria-label="More actions for this machine" |
| 3280 | Update to match this client. | ||
| 3281 | </button> | ||
| 3282 | } | ||
| 3283 | /> | 3260 | /> |
| 3284 | <TooltipPopup side="top"> | 3261 | } |
| 3285 | {primaryVersionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "} | 3262 | > |
| 3286 | {primaryVersionMismatch.clientVersion} | 3263 | <EllipsisIcon className="size-3.5" /> |
| 3287 | </TooltipPopup> | 3264 | </MenuTrigger> |
| 3288 | </Tooltip> | 3265 | <MenuPopup align="end" className="min-w-52"> |
| 3289 | ) : null | 3266 | <EnvironmentIconMenu |
| 3290 | } | ||
| 3291 | control={ | ||
| 3292 | primaryVersionMismatch && | ||
| 3293 | primaryEnvironmentId !== null && | ||
| 3294 | primaryServerUpdateState.status !== "running" ? ( | ||
| 3295 | <ServerUpdateAction | ||
| 3296 | size="sm" | ||
| 3297 | environmentId={primaryEnvironmentId} | 3267 | environmentId={primaryEnvironmentId} |
| 3298 | serverLabel={ | 3268 | serverConfig={primaryServerConfig} |
| 3299 | primaryEnvironment ? `${primaryEnvironment.label} server` : "server" | ||
| 3300 | } | ||
| 3301 | selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} | ||
| 3302 | desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)} | ||
| 3303 | threadContinuation={supportsServerUpdateThreadContinuation( | ||
| 3304 | primaryServerConfig, | ||
| 3305 | )} | ||
| 3306 | targetVersion={primaryVersionMismatch.clientVersion} | ||
| 3307 | label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"} | ||
| 3308 | /> | 3269 | /> |
| 3309 | ) : undefined | 3270 | </MenuPopup> |
| 3310 | } | 3271 | </Menu> |
| 3311 | /> | 3272 | ) : null |
| 3312 | ) : null} | 3273 | } |
| 3313 | {primaryEnvironmentId !== null ? ( | 3274 | > |
| 3314 | <SettingsRow | 3275 | <SettingsRow |
| 3315 | {...searchableSetting("environment-icon")} | 3276 | title="Version" |
| 3316 | description="The machine other devices see this environment as. Automatic uses what the server detected." | 3277 | description={ |
| 3317 | control={ | 3278 | primaryServerUpdateState.status !== "idle" ? ( |
| 3318 | <EnvironmentIconPicker | 3279 | <ServerUpdateProgress state={primaryServerUpdateState} /> |
| 3280 | ) : ( | ||
| 3281 | [ | ||
| 3282 | primaryServerConfig?.environment.serverVersion ?? null, | ||
| 3283 | primaryEnvironment?.displayUrl ?? null, | ||
| 3284 | ] | ||
| 3285 | .filter((value): value is string => value !== null) | ||
| 3286 | .join(" · ") || "Loading…" | ||
| 3287 | ) | ||
| 3288 | } | ||
| 3289 | control={ | ||
| 3290 | primaryVersionMismatch && | ||
| 3291 | primaryEnvironmentId !== null && | ||
| 3292 | primaryServerUpdateState.status !== "running" ? ( | ||
| 3293 | <ServerUpdateAction | ||
| 3294 | size="sm" | ||
| 3319 | environmentId={primaryEnvironmentId} | 3295 | environmentId={primaryEnvironmentId} |
| 3320 | serverConfig={primaryServerConfig} | 3296 | serverLabel={ |
| 3297 | primaryEnvironment ? `${primaryEnvironment.label} server` : "server" | ||
| 3298 | } | ||
| 3299 | selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} | ||
| 3300 | desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)} | ||
| 3301 | threadContinuation={supportsServerUpdateThreadContinuation(primaryServerConfig)} | ||
| 3302 | targetVersion={primaryVersionMismatch.clientVersion} | ||
| 3303 | label={ | ||
| 3304 | primaryServerUpdateState.status === "failed" | ||
| 3305 | ? "Retry update" | ||
| 3306 | : `Update to ${primaryVersionMismatch.clientVersion}` | ||
| 3307 | } | ||
| 3321 | /> | 3308 | /> |
| 3322 | } | 3309 | ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( |
| 3323 | /> | 3310 | <span className="text-xs text-muted-foreground">Up to date</span> |
| 3324 | ) : null} | 3311 | ) : undefined |
| 3312 | } | ||
| 3313 | /> | ||
| 3325 | {desktopBridge ? ( | 3314 | {desktopBridge ? ( |
| 3326 | <> | 3315 | <> |
| 3327 | {renderNetworkAccessRow()} | 3316 | {renderNetworkAccessRow()} |
| ... | @@ -3339,10 +3328,14 @@ export function ConnectionsSettings() { | ... | @@ -3339,10 +3328,14 @@ export function ConnectionsSettings() { |
| 3339 | </SettingsSection> | 3328 | </SettingsSection> |
| 3340 | 3329 | ||
| 3341 | {isLocalBackendRemotelyReachable ? ( | 3330 | {isLocalBackendRemotelyReachable ? ( |
| 3342 | <SettingsSection | 3331 | <FoldedSettingsSection |
| 3332 | id="authorized-clients" | ||
| 3343 | title="Authorized clients" | 3333 | title="Authorized clients" |
| 3344 | className="[&>div:first-child]:flex-wrap" | 3334 | summary={summarizeAuthorizedClients( |
| 3345 | headerAction={ | 3335 | desktopClientSessions, |
| 3336 | visibleDesktopPairingLinks, | ||
| 3337 | )} | ||
| 3338 | control={ | ||
| 3346 | <AuthorizedClientsHeaderAction | 3339 | <AuthorizedClientsHeaderAction |
| 3347 | onPairingLinkCreated={handlePairingLinkCreated} | 3340 | onPairingLinkCreated={handlePairingLinkCreated} |
| 3348 | clientSessions={desktopClientSessions} | 3341 | clientSessions={desktopClientSessions} |
| ... | @@ -3359,7 +3352,7 @@ export function ConnectionsSettings() { | ... | @@ -3359,7 +3352,7 @@ export function ConnectionsSettings() { |
| 3359 | > | 3352 | > |
| 3360 | {renderAuthorizedClients("current")} | 3353 | {renderAuthorizedClients("current")} |
| 3361 | </ScrollArea> | 3354 | </ScrollArea> |
| 3362 | </SettingsSection> | 3355 | </FoldedSettingsSection> |
| 3363 | ) : null} | 3356 | ) : null} |
| 3364 | <AlertDialog | 3357 | <AlertDialog |
| 3365 | open={isDesktopServerExposureDialogOpen} | 3358 | open={isDesktopServerExposureDialogOpen} |
| ... | @@ -3649,10 +3642,10 @@ export function ConnectionsSettings() { | ... | @@ -3649,10 +3642,10 @@ export function ConnectionsSettings() { |
| 3649 | 3642 | ||
| 3650 | return ( | 3643 | return ( |
| 3651 | <SettingsPageContainer width="wide"> | 3644 | <SettingsPageContainer width="wide"> |
| 3645 | {primarySettings} | ||
| 3652 | <SettingsSection | 3646 | <SettingsSection |
| 3653 | {...searchableSetting("remote-environments")} | 3647 | {...searchableSetting("remote-environments")} |
| 3654 | title="Environments" | 3648 | title="Environments" |
| 3655 | variant="plain" | ||
| 3656 | headerAction={ | 3649 | headerAction={ |
| 3657 | <div className="flex items-center gap-1"> | 3650 | <div className="flex items-center gap-1"> |
| 3658 | {savedServerUpdateTargets.length > 0 ? ( | 3651 | {savedServerUpdateTargets.length > 0 ? ( |
| ... | @@ -3725,118 +3718,22 @@ export function ConnectionsSettings() { | ... | @@ -3725,118 +3718,22 @@ export function ConnectionsSettings() { |
| 3725 | </div> | 3718 | </div> |
| 3726 | } | 3719 | } |
| 3727 | > | 3720 | > |
| 3728 | <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5 lg:grid lg:h-[min(34rem,calc(100dvh-18rem))] lg:min-h-[28rem] lg:grid-cols-[17rem_minmax(0,1fr)]"> | 3721 | {listedEnvironments.map((environment) => ( |
| 3729 | <div className="border-b border-border/60 bg-muted/10 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0"> | 3722 | <SavedBackendListRow |
| 3730 | <ScrollArea | 3723 | key={environment.environmentId} |
| 3731 | scrollFade | 3724 | environment={environment} |
| 3732 | chainVerticalScroll | 3725 | removingEnvironmentId={removingSavedEnvironmentId} |
| 3733 | className="max-h-64 lg:max-h-none lg:min-h-0 lg:flex-1" | 3726 | onSetEnabled={handleSetSavedBackendEnabled} |
| 3734 | > | 3727 | onRemove={handleRemoveSavedBackend} |
| 3735 | <div className="divide-y divide-border/50" aria-label="Environments"> | 3728 | /> |
| 3736 | {(primaryEnvironment | 3729 | ))} |
| 3737 | ? [primaryEnvironment, ...savedEnvironments] | ||
| 3738 | : savedEnvironments | ||
| 3739 | ).map((environment) => { | ||
| 3740 | const selected = environment.environmentId === selectedEnvironment?.environmentId; | ||
| 3741 | const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; | ||
| 3742 | return ( | ||
| 3743 | <button | ||
| 3744 | key={environment.environmentId} | ||
| 3745 | type="button" | ||
| 3746 | aria-label={"Select " + environment.label} | ||
| 3747 | aria-pressed={selected} | ||
| 3748 | onClick={() => setSelectedEnvironmentId(environment.environmentId)} | ||
| 3749 | className={cn( | ||
| 3750 | "flex min-h-18 w-full cursor-pointer items-start gap-3 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:px-4", | ||
| 3751 | selected ? "bg-muted/45" : "hover:bg-muted/25", | ||
| 3752 | !environment.entry.enabled && !selected && "opacity-60", | ||
| 3753 | )} | ||
| 3754 | > | ||
| 3755 | <EnvironmentMachineIcon | ||
| 3756 | aria-hidden | ||
| 3757 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | ||
| 3758 | className="mt-0.5 size-4 shrink-0 text-muted-foreground" | ||
| 3759 | /> | ||
| 3760 | <span className="min-w-0 flex-1 space-y-1"> | ||
| 3761 | <span className="flex items-center gap-2"> | ||
| 3762 | <span className="truncate text-sm font-medium">{environment.label}</span> | ||
| 3763 | {isPrimary ? ( | ||
| 3764 | <span className="shrink-0 text-[10px] text-muted-foreground"> | ||
| 3765 | Primary | ||
| 3766 | </span> | ||
| 3767 | ) : null} | ||
| 3768 | </span> | ||
| 3769 | <Tooltip> | ||
| 3770 | <TooltipTrigger | ||
| 3771 | render={ | ||
| 3772 | <span className="block truncate text-xs text-muted-foreground" /> | ||
| 3773 | } | ||
| 3774 | > | ||
| 3775 | {environment.displayUrl ?? "T3 Connect"} | ||
| 3776 | </TooltipTrigger> | ||
| 3777 | <TooltipPopup>{environment.displayUrl ?? "T3 Connect"}</TooltipPopup> | ||
| 3778 | </Tooltip> | ||
| 3779 | <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 3780 | <span | ||
| 3781 | aria-hidden | ||
| 3782 | className={cn( | ||
| 3783 | "size-1.5 shrink-0 rounded-full", | ||
| 3784 | !environment.entry.enabled | ||
| 3785 | ? "bg-muted-foreground/40" | ||
| 3786 | : connectionPhaseDotClassName(environment.connection.phase), | ||
| 3787 | )} | ||
| 3788 | /> | ||
| 3789 | {environment.entry.enabled | ||
| 3790 | ? connectionStatusTitle(environment.connection) | ||
| 3791 | : "Off"} | ||
| 3792 | </span> | ||
| 3793 | </span> | ||
| 3794 | </button> | ||
| 3795 | ); | ||
| 3796 | })} | ||
| 3797 | </div> | ||
| 3798 | </ScrollArea> | ||
| 3799 | </div> | ||
| 3800 | <ScrollArea | ||
| 3801 | key={selectedEnvironment?.environmentId} | ||
| 3802 | scrollFade | ||
| 3803 | chainVerticalScroll | ||
| 3804 | className="min-w-0 lg:min-h-0 lg:h-full" | ||
| 3805 | > | ||
| 3806 | {selectedEnvironment ? ( | ||
| 3807 | <div className="space-y-6 p-4"> | ||
| 3808 | {selectedEnvironment.entry.target._tag === "PrimaryConnectionTarget" ? ( | ||
| 3809 | primarySettings | ||
| 3810 | ) : ( | ||
| 3811 | <SettingsSection title="Connection"> | ||
| 3812 | <SavedBackendListRow | ||
| 3813 | key={selectedEnvironment.environmentId} | ||
| 3814 | environment={selectedEnvironment} | ||
| 3815 | removingEnvironmentId={removingSavedEnvironmentId} | ||
| 3816 | onSetEnabled={handleSetSavedBackendEnabled} | ||
| 3817 | onRemove={handleRemoveSavedBackend} | ||
| 3818 | /> | ||
| 3819 | </SettingsSection> | ||
| 3820 | )} | ||
| 3821 | {selectedEnvironment.entry.enabled && loadBalancingEnvironments.length > 1 ? ( | ||
| 3822 | <SettingsSection title="Thread placement"> | ||
| 3823 | <LoadBalancingPreference environment={selectedEnvironment} /> | ||
| 3824 | </SettingsSection> | ||
| 3825 | ) : null} | ||
| 3826 | <GitHubRoutingSettings | ||
| 3827 | environments={environments} | ||
| 3828 | selectedEnvironmentId={selectedEnvironment.environmentId} | ||
| 3829 | /> | ||
| 3830 | </div> | ||
| 3831 | ) : null} | ||
| 3832 | </ScrollArea> | ||
| 3833 | </div> | ||
| 3834 | <CloudRemoteEnvironmentRows | 3730 | <CloudRemoteEnvironmentRows |
| 3835 | primaryEnvironmentId={primaryEnvironmentId} | 3731 | primaryEnvironmentId={primaryEnvironmentId} |
| 3836 | savedEnvironments={savedEnvironments} | 3732 | savedEnvironments={savedEnvironments} |
| 3837 | /> | 3733 | /> |
| 3838 | </SettingsSection> | 3734 | </SettingsSection> |
| 3839 | <LoadBalancingSettings environments={loadBalancingEnvironments} /> | 3735 | <LoadBalancingSettings environments={loadBalancingEnvironments} /> |
| 3736 | <GitHubRoutingSettings environments={loadBalancingEnvironments} /> | ||
| 3840 | </SettingsPageContainer> | 3737 | </SettingsPageContainer> |
| 3841 | ); | 3738 | ); |
| 3842 | } | 3739 | } |
apps/web/src/components/settings/EnvironmentIconPicker.tsx+57-81| ... | @@ -5,7 +5,6 @@ import { | ... | @@ -5,7 +5,6 @@ import { |
| 5 | type EnvironmentId, | 5 | type EnvironmentId, |
| 6 | type ServerConfig, | 6 | type ServerConfig, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { useCallback } from "react"; | ||
| 9 | 8 | ||
| 10 | import { isElectron } from "../../env"; | 9 | import { isElectron } from "../../env"; |
| 11 | import { usePrimarySessionState } from "../../environments/primary"; | 10 | import { usePrimarySessionState } from "../../environments/primary"; |
| ... | @@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | ... | @@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; |
| 13 | import { usePrimaryEnvironmentId } from "../../state/environments"; | 12 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 14 | import { useEnvironmentSessionState } from "../../state/session"; | 13 | import { useEnvironmentSessionState } from "../../state/session"; |
| 15 | import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | 14 | import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 16 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 15 | import { |
| 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 16 | MenuItem, |
| 17 | MenuRadioGroup, | ||
| 18 | MenuRadioItem, | ||
| 19 | MenuSeparator, | ||
| 20 | MenuSub, | ||
| 21 | MenuSubPopup, | ||
| 22 | MenuSubTrigger, | ||
| 23 | } from "../ui/menu"; | ||
| 18 | import { | 24 | import { |
| 19 | resolvePrimaryOperateAccess, | 25 | resolvePrimaryOperateAccess, |
| 20 | resolveRemoteOperateAccess, | 26 | resolveRemoteOperateAccess, |
| 21 | } from "./ProviderSettingsPanel.logic"; | 27 | } from "./ProviderSettingsPanel.logic"; |
| 22 | 28 | ||
| 23 | const AUTOMATIC_VALUE = "automatic"; | ||
| 24 | |||
| 25 | /** | 29 | /** |
| 26 | * Why the picker is inert, in the order the user can do something about it. | 30 | * Why the picker is inert, in the order the user can do something about it. |
| 27 | * Null means it can be changed. | 31 | * Null means it can be changed. |
| ... | @@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) { | ... | @@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) { |
| 68 | } | 72 | } |
| 69 | 73 | ||
| 70 | /** | 74 | /** |
| 71 | * Picks the machine glyph an environment wears everywhere it is listed. | 75 | * "Icon" submenu for an environment's row menu. Lists the machine kinds with |
| 72 | * "Automatic" clears the override so the server's own detection shows | 76 | * the server's own detection marked, so the user can tell whether detection |
| 73 | * through; the label says what that currently resolves to so the user can | 77 | * got it right before overriding. Picking the detected kind clears the |
| 74 | * tell whether detection got it right before overriding. The control stays | 78 | * override. Locked environments show the reason as a disabled item instead of |
| 75 | * visible while locked so the current icon still reads, the same way | 79 | * hiding the submenu, so the current icon still reads. |
| 76 | * server-scoped rows go inert instead of disappearing. | ||
| 77 | */ | 80 | */ |
| 78 | export function EnvironmentIconPicker({ | 81 | export function EnvironmentIconMenu({ |
| 79 | environmentId, | 82 | environmentId, |
| 80 | serverConfig, | 83 | serverConfig, |
| 81 | size = "sm", | ||
| 82 | }: { | 84 | }: { |
| 83 | readonly environmentId: EnvironmentId; | 85 | readonly environmentId: EnvironmentId; |
| 84 | readonly serverConfig: ServerConfig | null; | 86 | readonly serverConfig: ServerConfig | null; |
| 85 | readonly size?: "xs" | "sm"; | ||
| 86 | }) { | 87 | }) { |
| 87 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | 88 | const updateSettings = useUpdateEnvironmentSettings(environmentId); |
| 88 | const operateAccess = useEnvironmentOperateAccess(environmentId); | 89 | const operateAccess = useEnvironmentOperateAccess(environmentId); |
| 89 | const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); | 90 | const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); |
| 90 | const override = serverConfig?.settings.environmentIcon ?? null; | 91 | // With no detection the server falls back to "server", so picking that |
| 91 | const detected = serverConfig?.environment.platform.machine ?? null; | 92 | // kind clears the override the same way picking the detected kind does. |
| 93 | const detected = serverConfig?.environment.platform.machine ?? "server"; | ||
| 92 | const resolved = resolveEnvironmentMachineKind(serverConfig); | 94 | const resolved = resolveEnvironmentMachineKind(serverConfig); |
| 93 | const value = override ?? AUTOMATIC_VALUE; | ||
| 94 | const automaticLabel = | ||
| 95 | detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`; | ||
| 96 | |||
| 97 | const handleValueChange = useCallback( | ||
| 98 | (next: string | null) => { | ||
| 99 | if (next === null) return; | ||
| 100 | if (next === AUTOMATIC_VALUE) { | ||
| 101 | updateSettings({ environmentIcon: null }); | ||
| 102 | } else if (isEnvironmentMachineKind(next)) { | ||
| 103 | updateSettings({ environmentIcon: next }); | ||
| 104 | } | ||
| 105 | }, | ||
| 106 | [updateSettings], | ||
| 107 | ); | ||
| 108 | 95 | ||
| 109 | const select = ( | ||
| 110 | <Select value={value} onValueChange={handleValueChange} disabled={lock !== null}> | ||
| 111 | <SelectTrigger size={size} className="w-full sm:w-52" aria-label="Environment icon"> | ||
| 112 | <SelectValue> | ||
| 113 | <span className="flex min-w-0 items-center gap-2"> | ||
| 114 | <EnvironmentMachineIcon kind={resolved} className="size-3.5 shrink-0" /> | ||
| 115 | <span className="truncate"> | ||
| 116 | {override === null ? automaticLabel : ENVIRONMENT_MACHINE_KIND_LABELS[override]} | ||
| 117 | </span> | ||
| 118 | </span> | ||
| 119 | </SelectValue> | ||
| 120 | </SelectTrigger> | ||
| 121 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 122 | <SelectItem hideIndicator value={AUTOMATIC_VALUE}> | ||
| 123 | <span className="flex min-w-0 items-center gap-2"> | ||
| 124 | <EnvironmentMachineIcon kind={detected ?? "server"} className="size-3.5 shrink-0" /> | ||
| 125 | <span className="truncate">{automaticLabel}</span> | ||
| 126 | </span> | ||
| 127 | </SelectItem> | ||
| 128 | {ENVIRONMENT_MACHINE_KINDS.map((kind) => ( | ||
| 129 | <SelectItem hideIndicator key={kind} value={kind}> | ||
| 130 | <span className="flex min-w-0 items-center gap-2"> | ||
| 131 | <EnvironmentMachineIcon kind={kind} className="size-3.5 shrink-0" /> | ||
| 132 | <span className="truncate">{ENVIRONMENT_MACHINE_KIND_LABELS[kind]}</span> | ||
| 133 | </span> | ||
| 134 | </SelectItem> | ||
| 135 | ))} | ||
| 136 | </SelectPopup> | ||
| 137 | </Select> | ||
| 138 | ); | ||
| 139 | |||
| 140 | if (lock === null) { | ||
| 141 | return select; | ||
| 142 | } | ||
| 143 | return ( | 96 | return ( |
| 144 | <Tooltip> | 97 | <MenuSub> |
| 145 | <TooltipTrigger | 98 | <MenuSubTrigger> |
| 146 | render={ | 99 | <EnvironmentMachineIcon kind={resolved} /> |
| 147 | // Focusable so keyboard users can still reach the explanation. | 100 | Icon |
| 148 | <span | 101 | </MenuSubTrigger> |
| 149 | tabIndex={0} | 102 | <MenuSubPopup className="min-w-44"> |
| 150 | className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto" | 103 | {lock !== null ? ( |
| 151 | /> | 104 | <> |
| 152 | } | 105 | <MenuItem disabled className="whitespace-normal text-xs"> |
| 153 | > | 106 | {lock} |
| 154 | <span className="flex w-full items-center sm:w-auto">{select}</span> | 107 | </MenuItem> |
| 155 | </TooltipTrigger> | 108 | <MenuSeparator /> |
| 156 | <TooltipPopup side="top" className="max-w-72"> | 109 | </> |
| 157 | {lock} | 110 | ) : null} |
| 158 | </TooltipPopup> | 111 | <MenuRadioGroup |
| 159 | </Tooltip> | 112 | value={resolved} |
| 113 | onValueChange={(next) => { | ||
| 114 | if (lock !== null || !isEnvironmentMachineKind(next)) return; | ||
| 115 | updateSettings({ environmentIcon: next === detected ? null : next }); | ||
| 116 | }} | ||
| 117 | > | ||
| 118 | {ENVIRONMENT_MACHINE_KINDS.map((kind) => ( | ||
| 119 | <MenuRadioItem key={kind} value={kind} disabled={lock !== null}> | ||
| 120 | <span className="flex min-w-0 items-center gap-2"> | ||
| 121 | <EnvironmentMachineIcon kind={kind} className="size-3.5 shrink-0" /> | ||
| 122 | <span className="min-w-0 flex-1 truncate"> | ||
| 123 | {ENVIRONMENT_MACHINE_KIND_LABELS[kind]} | ||
| 124 | </span> | ||
| 125 | {kind === detected ? ( | ||
| 126 | <span className="shrink-0 text-xs text-muted-foreground"> | ||
| 127 | {serverConfig?.environment.platform.machine ? "detected" : "default"} | ||
| 128 | </span> | ||
| 129 | ) : null} | ||
| 130 | </span> | ||
| 131 | </MenuRadioItem> | ||
| 132 | ))} | ||
| 133 | </MenuRadioGroup> | ||
| 134 | </MenuSubPopup> | ||
| 135 | </MenuSub> | ||
| 160 | ); | 136 | ); |
| 161 | } | 137 | } |
apps/web/src/components/settings/EnvironmentRow.tsx created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import type { ReactNode } from "react"; | ||
| 4 | |||
| 5 | import { cn } from "~/lib/utils"; | ||
| 6 | import type { EnvironmentPresentation } from "~/state/environments"; | ||
| 7 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | ||
| 8 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | ||
| 9 | |||
| 10 | export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { | ||
| 11 | const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; | ||
| 12 | return target.port ? `${authority}:${target.port}` : authority; | ||
| 13 | } | ||
| 14 | |||
| 15 | /** | ||
| 16 | * How this client reaches a machine, printed first in every environment row so | ||
| 17 | * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend. | ||
| 18 | */ | ||
| 19 | export function environmentTransportLabel(environment: EnvironmentPresentation): string { | ||
| 20 | const { entry } = environment; | ||
| 21 | if (entry.target._tag === "PrimaryConnectionTarget") return "This machine"; | ||
| 22 | if (environment.relayManaged) return "T3 Connect"; | ||
| 23 | if (isDesktopLocalConnectionTarget(entry.target)) return "WSL"; | ||
| 24 | if ( | ||
| 25 | entry.target._tag === "SshConnectionTarget" && | ||
| 26 | Option.isSome(entry.profile) && | ||
| 27 | entry.profile.value._tag === "SshConnectionProfile" | ||
| 28 | ) { | ||
| 29 | return `SSH ${formatDesktopSshTarget(entry.profile.value.target)}`; | ||
| 30 | } | ||
| 31 | return environment.displayUrl ?? "Remote link"; | ||
| 32 | } | ||
| 33 | |||
| 34 | /** | ||
| 35 | * One machine in a grouped settings list: icon, name, a single subtitle line, | ||
| 36 | * and controls on the right. Every environment list on the Connections page | ||
| 37 | * uses this so the lists share one rhythm. | ||
| 38 | */ | ||
| 39 | export function EnvironmentRow({ | ||
| 40 | kind, | ||
| 41 | label, | ||
| 42 | subtitle, | ||
| 43 | below, | ||
| 44 | dimmed = false, | ||
| 45 | className, | ||
| 46 | children, | ||
| 47 | }: { | ||
| 48 | readonly kind: EnvironmentMachineKind; | ||
| 49 | readonly label: string; | ||
| 50 | readonly subtitle: ReactNode; | ||
| 51 | /** Extra content under the subtitle, such as update progress. */ | ||
| 52 | readonly below?: ReactNode; | ||
| 53 | readonly dimmed?: boolean; | ||
| 54 | readonly className?: string; | ||
| 55 | readonly children?: ReactNode; | ||
| 56 | }) { | ||
| 57 | return ( | ||
| 58 | <div | ||
| 59 | className={cn( | ||
| 60 | "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 sm:px-4", | ||
| 61 | dimmed && "opacity-60", | ||
| 62 | className, | ||
| 63 | )} | ||
| 64 | > | ||
| 65 | <EnvironmentMachineIcon aria-hidden kind={kind} className="size-4 text-muted-foreground" /> | ||
| 66 | <div className="min-w-0"> | ||
| 67 | <p className="truncate text-sm font-medium text-foreground">{label}</p> | ||
| 68 | <div className="truncate text-xs text-muted-foreground">{subtitle}</div> | ||
| 69 | {below} | ||
| 70 | </div> | ||
| 71 | <div className="flex shrink-0 items-center gap-1">{children}</div> | ||
| 72 | </div> | ||
| 73 | ); | ||
| 74 | } | ||
apps/web/src/components/settings/FoldedSettingsSection.tsx created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import { ChevronRightIcon } from "lucide-react"; | ||
| 2 | import { type ReactNode, useState } from "react"; | ||
| 3 | |||
| 4 | import { cn } from "~/lib/utils"; | ||
| 5 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | ||
| 6 | import { useSettingsSearchTarget, useSettingsSearchTargetId } from "./settingsLayout"; | ||
| 7 | |||
| 8 | /** | ||
| 9 | * A grouped settings section that starts closed. The header carries the title, | ||
| 10 | * a one line summary of what is set inside, and an optional control such as | ||
| 11 | * the section's own switch. A settings search that targets the section opens it. | ||
| 12 | */ | ||
| 13 | export function FoldedSettingsSection({ | ||
| 14 | id, | ||
| 15 | title, | ||
| 16 | summary, | ||
| 17 | control, | ||
| 18 | children, | ||
| 19 | }: { | ||
| 20 | readonly id: string; | ||
| 21 | readonly title: string; | ||
| 22 | readonly summary?: string | null; | ||
| 23 | readonly control?: ReactNode; | ||
| 24 | readonly children: ReactNode; | ||
| 25 | }) { | ||
| 26 | const [open, setOpen] = useState(false); | ||
| 27 | const searchTargetId = useSettingsSearchTargetId(); | ||
| 28 | const targetRef = useSettingsSearchTarget<HTMLElement>(id); | ||
| 29 | // A search jump lands inside the fold, so open it before the scroll runs. | ||
| 30 | const [openedForTarget, setOpenedForTarget] = useState<string | null>(null); | ||
| 31 | if (searchTargetId === id && openedForTarget !== id) { | ||
| 32 | setOpenedForTarget(id); | ||
| 33 | if (!open) setOpen(true); | ||
| 34 | } | ||
| 35 | |||
| 36 | return ( | ||
| 37 | <section id={id} ref={targetRef} tabIndex={-1} className="outline-none"> | ||
| 38 | <Collapsible | ||
| 39 | open={open} | ||
| 40 | onOpenChange={setOpen} | ||
| 41 | className="rounded-xl border border-border/60 bg-card/40 text-foreground shadow-xs/5" | ||
| 42 | > | ||
| 43 | <div className="flex items-center gap-4 px-3 sm:px-4"> | ||
| 44 | <CollapsibleTrigger className="flex min-h-11 min-w-0 flex-1 items-center gap-2 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md"> | ||
| 45 | <ChevronRightIcon | ||
| 46 | aria-hidden | ||
| 47 | className={cn( | ||
| 48 | "size-4 shrink-0 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none", | ||
| 49 | open && "rotate-90", | ||
| 50 | )} | ||
| 51 | /> | ||
| 52 | <span className="shrink-0 text-sm font-medium">{title}</span> | ||
| 53 | {summary ? ( | ||
| 54 | <span className="min-w-0 truncate text-xs text-muted-foreground">{summary}</span> | ||
| 55 | ) : null} | ||
| 56 | </CollapsibleTrigger> | ||
| 57 | {control ? <div className="flex shrink-0 items-center">{control}</div> : null} | ||
| 58 | </div> | ||
| 59 | <CollapsiblePanel> | ||
| 60 | <div className="border-t border-border/50 [&>*+*]:border-t [&>*+*]:border-border/50"> | ||
| 61 | {children} | ||
| 62 | </div> | ||
| 63 | </CollapsiblePanel> | ||
| 64 | </Collapsible> | ||
| 65 | </section> | ||
| 66 | ); | ||
| 67 | } | ||
apps/web/src/components/settings/GitHubRoutingSettings.test.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { summarizeGitHubRouting } from "./GitHubRoutingSettings"; | ||
| 4 | |||
| 5 | describe("summarizeGitHubRouting", () => { | ||
| 6 | it("is empty when no machine shares", () => { | ||
| 7 | expect(summarizeGitHubRouting([{ label: "alvin", permission: "off" }])).toBeNull(); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("groups sharing machines by permission, read and act first", () => { | ||
| 11 | expect( | ||
| 12 | summarizeGitHubRouting([ | ||
| 13 | { label: "alvin", permission: "read" }, | ||
| 14 | { label: "bb-1", permission: "read-write" }, | ||
| 15 | { label: "cup2", permission: "off" }, | ||
| 16 | { label: "Theo's MacBook Pro", permission: "read-write" }, | ||
| 17 | ]), | ||
| 18 | ).toBe("bb-1, Theo's MacBook Pro read and act · alvin read PRs"); | ||
| 19 | }); | ||
| 20 | }); | ||
apps/web/src/components/settings/GitHubRoutingSettings.tsx+90-56| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 3 | import { | 3 | import { |
| 4 | gitHubRoutingConnectionKey, | 4 | gitHubRoutingConnectionKey, |
| 5 | gitHubRoutingPermissionFor, | 5 | gitHubRoutingPermissionFor, |
| ... | @@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments"; | ... | @@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments"; |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | 12 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 13 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 13 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 14 | import { toastManager } from "../ui/toast"; | 14 | import { toastManager } from "../ui/toast"; |
| 15 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | 15 | import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; |
| 16 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; | ||
| 16 | import { searchableSetting } from "./settingsSearch"; | 17 | import { searchableSetting } from "./settingsSearch"; |
| 17 | 18 | ||
| 18 | const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [ | 19 | const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [ |
| ... | @@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> | ... | @@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> |
| 21 | { value: "read-write", label: "Read and act" }, | 22 | { value: "read-write", label: "Read and act" }, |
| 22 | ]; | 23 | ]; |
| 23 | 24 | ||
| 25 | const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Closed-header summary: the machines that share, grouped by permission. | ||
| 29 | * Null when nothing is shared. | ||
| 30 | */ | ||
| 31 | export function summarizeGitHubRouting( | ||
| 32 | entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>, | ||
| 33 | ): string | null { | ||
| 34 | const groups = (["read-write", "read"] as const).flatMap((permission) => { | ||
| 35 | const labels = entries.filter((entry) => entry.permission === permission); | ||
| 36 | return labels.length === 0 | ||
| 37 | ? [] | ||
| 38 | : [`${labels.map((entry) => entry.label).join(", ")} ${summaryLabels[permission]}`]; | ||
| 39 | }); | ||
| 40 | return groups.length === 0 ? null : groups.join(" · "); | ||
| 41 | } | ||
| 42 | |||
| 43 | /** | ||
| 44 | * Folded section under the environments list. One row per switched-on machine | ||
| 45 | * with how much of its GitHub access the other machines may use. The trust | ||
| 46 | * warning is the first line of the body so it sits next to the control. | ||
| 47 | * Rendered only when two or more machines are on. | ||
| 48 | */ | ||
| 24 | export function GitHubRoutingSettings({ | 49 | export function GitHubRoutingSettings({ |
| 25 | environments, | 50 | environments, |
| 26 | selectedEnvironmentId, | ||
| 27 | }: { | 51 | }: { |
| 28 | readonly environments: ReadonlyArray<EnvironmentPresentation>; | 52 | readonly environments: ReadonlyArray<EnvironmentPresentation>; |
| 29 | readonly selectedEnvironmentId: EnvironmentId; | ||
| 30 | }) { | 53 | }) { |
| 31 | const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); | 54 | const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); |
| 32 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); | 55 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); |
| 33 | const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); | 56 | const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); |
| 34 | const [saving, setSaving] = useState(false); | 57 | const [saving, setSaving] = useState(false); |
| 35 | const selectedEnvironments = environments.filter( | ||
| 36 | (environment) => environment.environmentId === selectedEnvironmentId, | ||
| 37 | ); | ||
| 38 | 58 | ||
| 59 | if (environments.length < 2) return null; | ||
| 60 | |||
| 61 | const { id, title } = searchableSetting("github-routing"); | ||
| 39 | return ( | 62 | return ( |
| 40 | <SettingsSection {...searchableSetting("github-routing")}> | 63 | <FoldedSettingsSection |
| 41 | <SettingsRow | 64 | id={id} |
| 42 | title="Share GitHub access" | 65 | title={title} |
| 43 | className="sm:[&>div]:grid-cols-1 [&_p]:max-w-none" | 66 | summary={ |
| 44 | description="Choose environments you trust to share PR data and use each other's GitHub access. Enable both environments. Read and act may use broader permissions than the original environment. This applies only to this client." | 67 | summarizeGitHubRouting( |
| 45 | /> | 68 | environments.map((environment) => ({ |
| 46 | {selectedEnvironments.map((environment) => ( | 69 | label: environment.label, |
| 47 | <SettingsRow | 70 | permission: gitHubRoutingPermissionFor(environment.entry, permissions), |
| 71 | })), | ||
| 72 | ) ?? "Off" | ||
| 73 | } | ||
| 74 | > | ||
| 75 | <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4"> | ||
| 76 | Machines you trust here can read PR data through each other's GitHub access. Enable both | ||
| 77 | machines. Read and act may use broader permissions than the machine that owns them. This | ||
| 78 | applies only to this device. | ||
| 79 | </p> | ||
| 80 | {environments.map((environment) => ( | ||
| 81 | <EnvironmentRow | ||
| 48 | key={environment.environmentId} | 82 | key={environment.environmentId} |
| 49 | title="GitHub access" | 83 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} |
| 50 | description={environment.displayUrl ?? "T3 Connect"} | 84 | label={environment.label} |
| 51 | control={ | 85 | subtitle={environmentTransportLabel(environment)} |
| 52 | <Select | 86 | > |
| 53 | items={options} | 87 | <Select |
| 54 | value={gitHubRoutingPermissionFor(environment.entry, permissions)} | 88 | items={options} |
| 55 | disabled={ | 89 | value={gitHubRoutingPermissionFor(environment.entry, permissions)} |
| 56 | !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null | 90 | disabled={ |
| 57 | } | 91 | !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null |
| 58 | onValueChange={(permission) => { | 92 | } |
| 59 | if (permission === null) return; | 93 | onValueChange={(permission) => { |
| 60 | setSaving(true); | 94 | if (permission === null) return; |
| 61 | void update({ environmentId: environment.environmentId, permission }).then( | 95 | setSaving(true); |
| 62 | (result) => { | 96 | void update({ environmentId: environment.environmentId, permission }).then( |
| 63 | setSaving(false); | 97 | (result) => { |
| 64 | if (result._tag === "Failure") | 98 | setSaving(false); |
| 65 | toastManager.add({ | 99 | if (result._tag === "Failure") |
| 66 | type: "error", | 100 | toastManager.add({ |
| 67 | title: "Could not save GitHub routing permission", | 101 | type: "error", |
| 68 | }); | 102 | title: "Could not save GitHub routing permission", |
| 69 | }, | 103 | }); |
| 70 | ); | 104 | }, |
| 71 | }} | 105 | ); |
| 106 | }} | ||
| 107 | > | ||
| 108 | <SelectTrigger | ||
| 109 | size="xs" | ||
| 110 | className="w-32" | ||
| 111 | aria-label={`${environment.label} GitHub routing`} | ||
| 72 | > | 112 | > |
| 73 | <SelectTrigger | 113 | <SelectValue /> |
| 74 | size="sm" | 114 | </SelectTrigger> |
| 75 | className="w-full sm:w-40" | 115 | <SelectPopup align="end" alignItemWithTrigger={false}> |
| 76 | aria-label={`${environment.label} GitHub routing`} | 116 | {options.map(({ value, label }) => ( |
| 77 | > | 117 | <SelectItem key={value} value={value}> |
| 78 | <SelectValue /> | 118 | {label} |
| 79 | </SelectTrigger> | 119 | </SelectItem> |
| 80 | <SelectPopup align="end" alignItemWithTrigger={false}> | 120 | ))} |
| 81 | {options.map(({ value, label }) => ( | 121 | </SelectPopup> |
| 82 | <SelectItem key={value} value={value}> | 122 | </Select> |
| 83 | {label} | 123 | </EnvironmentRow> |
| 84 | </SelectItem> | ||
| 85 | ))} | ||
| 86 | </SelectPopup> | ||
| 87 | </Select> | ||
| 88 | } | ||
| 89 | /> | ||
| 90 | ))} | 124 | ))} |
| 91 | </SettingsSection> | 125 | </FoldedSettingsSection> |
| 92 | ); | 126 | ); |
| 93 | } | 127 | } |
apps/web/src/components/settings/LoadBalancingSettings.test.ts created+32| ... | @@ -0,0 +1,32 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { loadPreferenceForWeight, summarizeLoadPreferences } from "./LoadBalancingSettings"; | ||
| 5 | |||
| 6 | const machines = [ | ||
| 7 | { environmentId: EnvironmentId.make("a"), label: "alvin" }, | ||
| 8 | { environmentId: EnvironmentId.make("b"), label: "bb-1" }, | ||
| 9 | { environmentId: EnvironmentId.make("c"), label: "ProMini" }, | ||
| 10 | ]; | ||
| 11 | |||
| 12 | describe("loadPreferenceForWeight", () => { | ||
| 13 | it("snaps legacy slider weights onto the four preferences", () => { | ||
| 14 | expect(loadPreferenceForWeight(undefined)).toBe(50); | ||
| 15 | expect(loadPreferenceForWeight(0)).toBe(0); | ||
| 16 | expect(loadPreferenceForWeight(10)).toBe(25); | ||
| 17 | expect(loadPreferenceForWeight(50)).toBe(50); | ||
| 18 | expect(loadPreferenceForWeight(80)).toBe(100); | ||
| 19 | }); | ||
| 20 | }); | ||
| 21 | |||
| 22 | describe("summarizeLoadPreferences", () => { | ||
| 23 | it("is empty when every machine is at Normal", () => { | ||
| 24 | expect(summarizeLoadPreferences(machines, { a: 50 })).toBeNull(); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("lists only the machines that differ from Normal, in list order", () => { | ||
| 28 | expect(summarizeLoadPreferences(machines, { b: 100, c: 0 })).toBe( | ||
| 29 | "bb-1 prefer · ProMini manual only", | ||
| 30 | ); | ||
| 31 | }); | ||
| 32 | }); | ||
apps/web/src/components/settings/LoadBalancingSettings.tsx+97-65| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | ||
| 2 | |||
| 1 | import { | 3 | import { |
| 2 | useClientSettings, | 4 | useClientSettings, |
| 3 | useClientSettingsHydrated, | 5 | useClientSettingsHydrated, |
| ... | @@ -6,7 +8,8 @@ import { | ... | @@ -6,7 +8,8 @@ import { |
| 6 | import type { EnvironmentPresentation } from "~/state/environments"; | 8 | import type { EnvironmentPresentation } from "~/state/environments"; |
| 7 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 9 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 8 | import { Switch } from "../ui/switch"; | 10 | import { Switch } from "../ui/switch"; |
| 9 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | 11 | import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; |
| 12 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; | ||
| 10 | import { searchableSetting } from "./settingsSearch"; | 13 | import { searchableSetting } from "./settingsSearch"; |
| 11 | 14 | ||
| 12 | const preferences = [ | 15 | const preferences = [ |
| ... | @@ -14,8 +17,44 @@ const preferences = [ | ... | @@ -14,8 +17,44 @@ const preferences = [ |
| 14 | { value: 50, label: "Normal" }, | 17 | { value: 50, label: "Normal" }, |
| 15 | { value: 25, label: "Less often" }, | 18 | { value: 25, label: "Less often" }, |
| 16 | { value: 0, label: "Manual only" }, | 19 | { value: 0, label: "Manual only" }, |
| 17 | ]; | 20 | ] as const; |
| 21 | |||
| 22 | type LoadPreference = (typeof preferences)[number]["value"]; | ||
| 23 | |||
| 24 | /** Snaps a saved weight (older builds stored a slider value) onto the four preferences. */ | ||
| 25 | export function loadPreferenceForWeight(weight: number | undefined): LoadPreference { | ||
| 26 | if (weight === undefined || weight === 50) return 50; | ||
| 27 | if (weight === 0) return 0; | ||
| 28 | return weight < 50 ? 25 : 100; | ||
| 29 | } | ||
| 30 | |||
| 31 | function preferenceLabel(preference: LoadPreference): string { | ||
| 32 | return preferences.find((entry) => entry.value === preference)!.label; | ||
| 33 | } | ||
| 18 | 34 | ||
| 35 | /** | ||
| 36 | * Closed-header summary: the machines not at Normal, so the folded section | ||
| 37 | * still tells you what is set. Null when every machine is at the default. | ||
| 38 | */ | ||
| 39 | export function summarizeLoadPreferences( | ||
| 40 | environments: ReadonlyArray<Pick<EnvironmentPresentation, "environmentId" | "label">>, | ||
| 41 | weights: Readonly<Record<string, number>>, | ||
| 42 | ): string | null { | ||
| 43 | const parts = environments.flatMap((environment) => { | ||
| 44 | const preference = loadPreferenceForWeight(weights[environment.environmentId]); | ||
| 45 | return preference === 50 | ||
| 46 | ? [] | ||
| 47 | : [`${environment.label} ${preferenceLabel(preference).toLowerCase()}`]; | ||
| 48 | }); | ||
| 49 | return parts.length === 0 ? null : parts.join(" · "); | ||
| 50 | } | ||
| 51 | |||
| 52 | /** | ||
| 53 | * Folded section under the environments list. Its switch turns balancing on | ||
| 54 | * for this client, and the body holds one row per switched-on machine with | ||
| 55 | * how often that machine should receive new threads. Rendered only when two | ||
| 56 | * or more machines are on, since one machine has nothing to balance against. | ||
| 57 | */ | ||
| 19 | export function LoadBalancingSettings({ | 58 | export function LoadBalancingSettings({ |
| 20 | environments, | 59 | environments, |
| 21 | }: { | 60 | }: { |
| ... | @@ -25,78 +64,71 @@ export function LoadBalancingSettings({ | ... | @@ -25,78 +64,71 @@ export function LoadBalancingSettings({ |
| 25 | const settingsHydrated = useClientSettingsHydrated(); | 64 | const settingsHydrated = useClientSettingsHydrated(); |
| 26 | const updateSettings = useUpdateClientSettings(); | 65 | const updateSettings = useUpdateClientSettings(); |
| 27 | 66 | ||
| 28 | if (environments.length < 2) { | 67 | if (environments.length < 2) return null; |
| 29 | return ( | ||
| 30 | <SettingsSection {...searchableSetting("load-balancing")} variant="plain"> | ||
| 31 | <p className="px-3 text-sm text-muted-foreground sm:px-4"> | ||
| 32 | Connect another machine to automatically balance load across environments. | ||
| 33 | </p> | ||
| 34 | </SettingsSection> | ||
| 35 | ); | ||
| 36 | } | ||
| 37 | 68 | ||
| 69 | const { id, title } = searchableSetting("load-balancing"); | ||
| 38 | return ( | 70 | return ( |
| 39 | <SettingsSection {...searchableSetting("load-balancing")}> | 71 | <FoldedSettingsSection |
| 40 | <SettingsRow | 72 | id={id} |
| 41 | title="Automatically balance load" | 73 | title={title} |
| 42 | description="Choose a machine automatically for new threads in shared projects." | 74 | summary={ |
| 43 | control={ | 75 | settings.loadBalancingEnabled |
| 44 | <Switch | 76 | ? summarizeLoadPreferences(environments, settings.loadBalancingWeights) |
| 45 | aria-label="Automatically balance load" | 77 | : "Off" |
| 46 | checked={settings.loadBalancingEnabled} | 78 | } |
| 47 | disabled={!settingsHydrated} | ||
| 48 | onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })} | ||
| 49 | /> | ||
| 50 | } | ||
| 51 | /> | ||
| 52 | </SettingsSection> | ||
| 53 | ); | ||
| 54 | } | ||
| 55 | |||
| 56 | export function LoadBalancingPreference({ environment }: { environment: EnvironmentPresentation }) { | ||
| 57 | const settings = useClientSettings(); | ||
| 58 | const settingsHydrated = useClientSettingsHydrated(); | ||
| 59 | const updateSettings = useUpdateClientSettings(); | ||
| 60 | const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; | ||
| 61 | // Keep saved slider weights until the user chooses a different preference. | ||
| 62 | const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; | ||
| 63 | |||
| 64 | return ( | ||
| 65 | <SettingsRow | ||
| 66 | title="Load preference" | ||
| 67 | description="How often this machine receives new threads when load balancing is on." | ||
| 68 | control={ | 79 | control={ |
| 69 | <Select | 80 | <Switch |
| 70 | items={preferences} | 81 | aria-label="Automatically balance load" |
| 71 | value={preference} | 82 | checked={settings.loadBalancingEnabled} |
| 72 | disabled={!settingsHydrated || !settings.loadBalancingEnabled} | 83 | disabled={!settingsHydrated} |
| 73 | onValueChange={(value) => { | 84 | onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })} |
| 74 | if (value !== null) { | 85 | /> |
| 86 | } | ||
| 87 | > | ||
| 88 | <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4"> | ||
| 89 | New threads in shared projects start on the machine with the most free CPU and memory, | ||
| 90 | weighted by each machine's preference. | ||
| 91 | </p> | ||
| 92 | {environments.map((environment) => ( | ||
| 93 | <EnvironmentRow | ||
| 94 | key={environment.environmentId} | ||
| 95 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | ||
| 96 | label={environment.label} | ||
| 97 | subtitle={environmentTransportLabel(environment)} | ||
| 98 | > | ||
| 99 | <Select | ||
| 100 | items={preferences} | ||
| 101 | value={loadPreferenceForWeight( | ||
| 102 | settings.loadBalancingWeights[environment.environmentId], | ||
| 103 | )} | ||
| 104 | disabled={!settingsHydrated || !settings.loadBalancingEnabled} | ||
| 105 | onValueChange={(value) => { | ||
| 106 | if (value === null) return; | ||
| 75 | updateSettings({ | 107 | updateSettings({ |
| 76 | loadBalancingWeights: { | 108 | loadBalancingWeights: { |
| 77 | ...settings.loadBalancingWeights, | 109 | ...settings.loadBalancingWeights, |
| 78 | [environment.environmentId]: value, | 110 | [environment.environmentId]: value, |
| 79 | }, | 111 | }, |
| 80 | }); | 112 | }); |
| 81 | } | 113 | }} |
| 82 | }} | ||
| 83 | > | ||
| 84 | <SelectTrigger | ||
| 85 | size="sm" | ||
| 86 | className="w-full sm:w-40" | ||
| 87 | aria-label={`${environment.label} load preference`} | ||
| 88 | > | 114 | > |
| 89 | <SelectValue /> | 115 | <SelectTrigger |
| 90 | </SelectTrigger> | 116 | size="xs" |
| 91 | <SelectPopup align="end" alignItemWithTrigger={false}> | 117 | className="w-32" |
| 92 | {preferences.map(({ value, label }) => ( | 118 | aria-label={`${environment.label} load preference`} |
| 93 | <SelectItem key={value} value={value}> | 119 | > |
| 94 | {label} | 120 | <SelectValue /> |
| 95 | </SelectItem> | 121 | </SelectTrigger> |
| 96 | ))} | 122 | <SelectPopup align="end" alignItemWithTrigger={false}> |
| 97 | </SelectPopup> | 123 | {preferences.map(({ value, label }) => ( |
| 98 | </Select> | 124 | <SelectItem key={value} value={value}> |
| 99 | } | 125 | {label} |
| 100 | /> | 126 | </SelectItem> |
| 127 | ))} | ||
| 128 | </SelectPopup> | ||
| 129 | </Select> | ||
| 130 | </EnvironmentRow> | ||
| 131 | ))} | ||
| 132 | </FoldedSettingsSection> | ||
| 101 | ); | 133 | ); |
| 102 | } | 134 | } |
apps/web/src/components/settings/settingsSearch.ts+3-3| ... | @@ -682,7 +682,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -682,7 +682,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 682 | }, | 682 | }, |
| 683 | { | 683 | { |
| 684 | id: "connections-environment", | 684 | id: "connections-environment", |
| 685 | title: "This environment", | 685 | title: "This machine", |
| 686 | to: "/settings/connections", | 686 | to: "/settings/connections", |
| 687 | searchTerms: [ | 687 | searchTerms: [ |
| 688 | "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", | 688 | "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 = [ | ... | @@ -690,7 +690,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 690 | }, | 690 | }, |
| 691 | { | 691 | { |
| 692 | id: "remote-environments", | 692 | id: "remote-environments", |
| 693 | title: "Remote environments", | 693 | title: "Environments", |
| 694 | to: "/settings/connections", | 694 | to: "/settings/connections", |
| 695 | searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], | 695 | searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], |
| 696 | }, | 696 | }, |
| ... | @@ -704,7 +704,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -704,7 +704,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 704 | }, | 704 | }, |
| 705 | { | 705 | { |
| 706 | id: "github-routing", | 706 | id: "github-routing", |
| 707 | title: "GitHub routing", | 707 | title: "GitHub sharing", |
| 708 | to: "/settings/connections", | 708 | to: "/settings/connections", |
| 709 | searchTerms: ["pull request trusted environments shared credentials permissions read actions"], | 709 | searchTerms: ["pull request trusted environments shared credentials permissions read actions"], |
| 710 | }, | 710 | }, |
docs/user/remote-access.md+2-1| ... | @@ -65,7 +65,8 @@ another link to share. | ... | @@ -65,7 +65,8 @@ another link to share. |
| 65 | 65 | ||
| 66 | Auto balance is off by default. On web and desktop, enable it in | 66 | Auto balance is off by default. On web and desktop, enable it in |
| 67 | **Settings → Connections → Load balancing** to automatically choose a machine for | 67 | **Settings → Connections → Load balancing** to automatically choose a machine for |
| 68 | new threads in projects grouped across connected environments. | 68 | new threads in projects grouped across connected environments. The section |
| 69 | appears once two or more machines are switched on. | ||
| 69 | Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and | 70 | Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and |
| 70 | memory available, **Less often** to reduce its share, or **Manual only** to exclude | 71 | memory available, **Less often** to reduce its share, or **Manual only** to exclude |
| 71 | it from automatic selection. These are preferences, not fixed traffic percentages. | 72 | it from automatic selection. These are preferences, not fixed traffic percentages. |
docs/user/source-control.md+1-1| ... | @@ -99,7 +99,7 @@ GitLab calls these merge requests. | ... | @@ -99,7 +99,7 @@ GitLab calls these merge requests. |
| 99 | GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also | 99 | GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also |
| 100 | supports approving waiting fork workflows and opening a revert pull request for a merged change. | 100 | supports approving waiting fork workflows and opening a revert pull request for a merged change. |
| 101 | 101 | ||
| 102 | GitHub routing is off by default. In Settings → Connections (Environments on mobile), choose | 102 | GitHub sharing is off by default. In Settings → Connections → GitHub sharing (Environments on mobile), choose |
| 103 | **Read PRs** or **Read and act** for each environment you trust to share GitHub access. | 103 | **Read PRs** or **Read and act** for each environment you trust to share GitHub access. |
| 104 | Enable both the original environment and the environment answering its requests on this client. | 104 | Enable both the original environment and the environment answering its requests on this client. |
| 105 | **Read and act** can use broader GitHub permissions than the original environment's credential; | 105 | **Read and act** can use broader GitHub permissions than the original environment's credential; |