|
| 1 | +import { Link, useNavigation } from "@remix-run/react"; |
| 2 | +import { motion } from "framer-motion"; |
| 3 | +import { useEffect, useState } from "react"; |
| 4 | +import { DropdownIcon } from "~/assets/icons/DropdownIcon"; |
| 5 | +import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; |
| 6 | +import { useFeatures } from "~/hooks/useFeatures"; |
| 7 | +import { type MatchedOrganization } from "~/hooks/useOrganizations"; |
| 8 | +import { cn } from "~/utils/cn"; |
| 9 | +import { v3BillingPath } from "~/utils/pathBuilder"; |
| 10 | +import { useDevPresence } from "../DevPresence"; |
| 11 | +import { environmentTextClassName } from "../environments/EnvironmentLabel"; |
| 12 | +import { |
| 13 | + Popover, |
| 14 | + PopoverContent, |
| 15 | + PopoverSectionHeader, |
| 16 | + PopoverTrigger, |
| 17 | +} from "../primitives/Popover"; |
| 18 | +import { SimpleTooltip } from "../primitives/Tooltip"; |
| 19 | +import { BranchesPopoverContent, EnvironmentSelector } from "./EnvironmentSelector"; |
| 20 | +import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; |
| 21 | + |
| 22 | +type EnvType = "DEVELOPMENT" | "STAGING" | "PREVIEW" | "PRODUCTION"; |
| 23 | + |
| 24 | +const SEGMENTS: { type: EnvType; label: string }[] = [ |
| 25 | + { type: "DEVELOPMENT", label: "Dev" }, |
| 26 | + { type: "STAGING", label: "Staging" }, |
| 27 | + { type: "PREVIEW", label: "Prev" }, |
| 28 | + { type: "PRODUCTION", label: "Prod" }, |
| 29 | +]; |
| 30 | + |
| 31 | +// Upgrade copy mirrors the dropdown's "Additional environments" prompts. |
| 32 | +const UPGRADE_MESSAGE: Partial<Record<EnvType, string>> = { |
| 33 | + STAGING: "Upgrade to unlock a Staging environment for your projects.", |
| 34 | + PREVIEW: "Upgrade to unlock Preview environments for your projects.", |
| 35 | +}; |
| 36 | + |
| 37 | +const PILL_LAYOUT_ID = "env-segmented-pill"; |
| 38 | + |
| 39 | +// The sliding selected pill is tinted with the env's own color. |
| 40 | +const ENV_PILL: Record<EnvType, string> = { |
| 41 | + DEVELOPMENT: "border-dev/30 bg-dev/15", |
| 42 | + STAGING: "border-staging/30 bg-staging/15", |
| 43 | + PREVIEW: "border-preview/30 bg-preview/15", |
| 44 | + PRODUCTION: "border-prod/30 bg-prod/15", |
| 45 | +}; |
| 46 | + |
| 47 | +const SEGMENT_CLASS = |
| 48 | + "group relative flex h-full grow items-center justify-center gap-1 rounded px-1.5 text-xs font-medium outline-none focus-custom"; |
| 49 | + |
| 50 | +/** |
| 51 | + * Side-menu-only environment switcher rendered as a segmented control (Dev / |
| 52 | + * Staging / Prev / Prod) instead of the dropdown `EnvironmentSelector`. The |
| 53 | + * dropdown is still used on blank-state panels and the Limits page. |
| 54 | + */ |
| 55 | +export function EnvironmentSegmentedControl({ |
| 56 | + organization, |
| 57 | + project, |
| 58 | + environment, |
| 59 | + isCollapsed = false, |
| 60 | +}: { |
| 61 | + organization: MatchedOrganization; |
| 62 | + project: SideMenuProject; |
| 63 | + environment: SideMenuEnvironment; |
| 64 | + isCollapsed?: boolean; |
| 65 | +}) { |
| 66 | + const { urlForEnvironment } = useEnvironmentSwitcher(); |
| 67 | + const { isManagedCloud } = useFeatures(); |
| 68 | + const { isConnected } = useDevPresence(); |
| 69 | + const navigation = useNavigation(); |
| 70 | + const [isPreviewOpen, setIsPreviewOpen] = useState(false); |
| 71 | + |
| 72 | + useEffect(() => { |
| 73 | + setIsPreviewOpen(false); |
| 74 | + }, [navigation.location?.pathname]); |
| 75 | + |
| 76 | + const rootEnvFor = (type: EnvType) => |
| 77 | + project.environments.find((e) => e.type === type && e.parentEnvironmentId === null); |
| 78 | + |
| 79 | + // Which segment is active. A branch (e.g. a preview branch) resolves to its |
| 80 | + // parent env's type, so the parent segment shows as active. |
| 81 | + const activeType: EnvType = (() => { |
| 82 | + if (environment.parentEnvironmentId) { |
| 83 | + const parent = project.environments.find((e) => e.id === environment.parentEnvironmentId); |
| 84 | + if (parent) return parent.type as EnvType; |
| 85 | + } |
| 86 | + return environment.type as EnvType; |
| 87 | + })(); |
| 88 | + |
| 89 | + // While the Preview popover is open, show Preview as the selected segment even |
| 90 | + // if we haven't navigated into a preview branch yet. |
| 91 | + const selectedType: EnvType = isPreviewOpen ? "PREVIEW" : activeType; |
| 92 | + |
| 93 | + // The dev connection is only tracked while you're in the dev environment (the |
| 94 | + // presence provider is enabled there), matching the old connection button. |
| 95 | + const showDevDot = environment.type === "DEVELOPMENT" && project.engine === "V2"; |
| 96 | + |
| 97 | + // When collapsed there's no room for the segmented control, so fall back to the |
| 98 | + // original dropdown selector (env icon + menu). |
| 99 | + if (isCollapsed) { |
| 100 | + return ( |
| 101 | + <EnvironmentSelector |
| 102 | + organization={organization} |
| 103 | + project={project} |
| 104 | + environment={environment} |
| 105 | + isCollapsed |
| 106 | + className="w-full" |
| 107 | + /> |
| 108 | + ); |
| 109 | + } |
| 110 | + |
| 111 | + return ( |
| 112 | + <div className="flex h-8 w-full items-center gap-x-0.5 rounded bg-charcoal-750 p-0.5"> |
| 113 | + {SEGMENTS.map((segment) => { |
| 114 | + const env = rootEnvFor(segment.type); |
| 115 | + const isSelected = selectedType === segment.type; |
| 116 | + const colorClass = isSelected |
| 117 | + ? environmentTextClassName({ type: segment.type }) |
| 118 | + : "text-text-dimmed transition group-hover:text-text-bright"; |
| 119 | + |
| 120 | + const pill = isSelected ? ( |
| 121 | + <motion.div |
| 122 | + layoutId={PILL_LAYOUT_ID} |
| 123 | + transition={{ duration: 0.4, type: "spring" }} |
| 124 | + className={cn("absolute inset-0 rounded border", ENV_PILL[segment.type])} |
| 125 | + /> |
| 126 | + ) : null; |
| 127 | + |
| 128 | + // Preview: opens the branch switcher popover on click (active or not). |
| 129 | + if (segment.type === "PREVIEW" && env) { |
| 130 | + const branchEnvironments = project.environments.filter( |
| 131 | + (e) => e.parentEnvironmentId === env.id |
| 132 | + ); |
| 133 | + // When the active environment is a preview branch, show the branch name |
| 134 | + // truncated to 4 characters (plus an ellipsis) instead of "Prev". |
| 135 | + const branchName = |
| 136 | + environment.parentEnvironmentId === env.id ? environment.branchName : null; |
| 137 | + const previewLabel = |
| 138 | + branchName && branchName.length > 4 |
| 139 | + ? `${branchName.slice(0, 4)}…` |
| 140 | + : (branchName ?? segment.label); |
| 141 | + return ( |
| 142 | + <Popover key={segment.type} open={isPreviewOpen} onOpenChange={setIsPreviewOpen}> |
| 143 | + <PopoverTrigger className={SEGMENT_CLASS}> |
| 144 | + {pill} |
| 145 | + <span className={cn("relative z-10 truncate", colorClass)}>{previewLabel}</span> |
| 146 | + <DropdownIcon className={cn("relative z-10 size-3.5 shrink-0", colorClass)} /> |
| 147 | + </PopoverTrigger> |
| 148 | + <PopoverContent |
| 149 | + className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600" |
| 150 | + align="start" |
| 151 | + side="bottom" |
| 152 | + sideOffset={6} |
| 153 | + style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} |
| 154 | + > |
| 155 | + <PopoverSectionHeader title="Preview environments" /> |
| 156 | + <BranchesPopoverContent |
| 157 | + parentEnvironment={env} |
| 158 | + branchEnvironments={branchEnvironments} |
| 159 | + currentEnvironment={environment} |
| 160 | + /> |
| 161 | + </PopoverContent> |
| 162 | + </Popover> |
| 163 | + ); |
| 164 | + } |
| 165 | + |
| 166 | + // Missing env (Staging/Preview not provisioned): upgrade link on managed |
| 167 | + // cloud, otherwise a disabled segment. |
| 168 | + if (!env) { |
| 169 | + const upgradeMessage = UPGRADE_MESSAGE[segment.type]; |
| 170 | + if (isManagedCloud && upgradeMessage) { |
| 171 | + return ( |
| 172 | + <Link |
| 173 | + key={segment.type} |
| 174 | + to={v3BillingPath(organization, upgradeMessage)} |
| 175 | + className={SEGMENT_CLASS} |
| 176 | + > |
| 177 | + <span className="relative z-10 truncate text-text-dimmed transition group-hover:text-text-bright"> |
| 178 | + {segment.label} |
| 179 | + </span> |
| 180 | + </Link> |
| 181 | + ); |
| 182 | + } |
| 183 | + return ( |
| 184 | + <div key={segment.type} className={cn(SEGMENT_CLASS, "cursor-default")} aria-disabled> |
| 185 | + <span className="relative z-10 truncate text-charcoal-600">{segment.label}</span> |
| 186 | + </div> |
| 187 | + ); |
| 188 | + } |
| 189 | + |
| 190 | + // Dev / Staging / Prod: navigate to the environment. |
| 191 | + return ( |
| 192 | + <Link key={segment.type} to={urlForEnvironment(env)} className={SEGMENT_CLASS}> |
| 193 | + {pill} |
| 194 | + <span className={cn("relative z-10 truncate", colorClass)}>{segment.label}</span> |
| 195 | + {segment.type === "DEVELOPMENT" && showDevDot ? ( |
| 196 | + <DevConnectionDot isConnected={isConnected} /> |
| 197 | + ) : null} |
| 198 | + </Link> |
| 199 | + ); |
| 200 | + })} |
| 201 | + </div> |
| 202 | + ); |
| 203 | +} |
| 204 | + |
| 205 | +/** |
| 206 | + * Small status dot (top-right of the Dev segment) replacing the old connection |
| 207 | + * button: a pulsing green dot when connected, a static grey dot otherwise. Hover |
| 208 | + * (after a short delay) reveals the connection status as a tooltip. |
| 209 | + */ |
| 210 | +function DevConnectionDot({ isConnected }: { isConnected: boolean | undefined }) { |
| 211 | + const content = |
| 212 | + isConnected === undefined |
| 213 | + ? "Checking connection…" |
| 214 | + : isConnected |
| 215 | + ? "Your dev server is connected" |
| 216 | + : "Your dev server is not connected"; |
| 217 | + |
| 218 | + return ( |
| 219 | + <SimpleTooltip |
| 220 | + asChild |
| 221 | + disableHoverableContent |
| 222 | + delayDuration={500} |
| 223 | + side="right" |
| 224 | + sideOffset={8} |
| 225 | + content={content} |
| 226 | + button={ |
| 227 | + <span className="absolute right-1 top-1 z-20 flex size-1.5"> |
| 228 | + {isConnected ? ( |
| 229 | + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-75" /> |
| 230 | + ) : null} |
| 231 | + <span |
| 232 | + className={cn( |
| 233 | + "relative inline-flex size-1.5 rounded-full", |
| 234 | + isConnected ? "bg-success" : "bg-charcoal-500" |
| 235 | + )} |
| 236 | + /> |
| 237 | + </span> |
| 238 | + } |
| 239 | + /> |
| 240 | + ); |
| 241 | +} |
0 commit comments