github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/SidebarItemView.tsx (about) 1 import React, { MutableRefObject, useEffect, useRef } from "react" 2 import TimeAgo from "react-timeago" 3 import styled from "styled-components" 4 import { Hold } from "./Hold" 5 import PathBuilder from "./PathBuilder" 6 import { useResourceNav } from "./ResourceNav" 7 import { SidebarBuildButton } from "./SidebarBuildButton" 8 import SidebarIcon from "./SidebarIcon" 9 import SidebarItem from "./SidebarItem" 10 import StarResourceButton, { 11 StarResourceButtonRoot, 12 } from "./StarResourceButton" 13 import { PendingBuildDescription } from "./status" 14 import { 15 AnimDuration, 16 barberpole, 17 Color, 18 ColorAlpha, 19 ColorRGBA, 20 Font, 21 FontSize, 22 mixinTruncateText, 23 overviewItemBorderRadius, 24 SizeUnit, 25 } from "./style-helpers" 26 import { formatBuildDuration, isZeroTime } from "./time" 27 import { timeAgoFormatter } from "./timeFormatters" 28 import { startBuild } from "./trigger" 29 import { ResourceStatus, ResourceView } from "./types" 30 import Tooltip from "./Tooltip" 31 32 export const SidebarItemRoot = styled.li` 33 & + & { 34 margin-top: ${SizeUnit(0.35)}; 35 } 36 37 &.isDisabled + &.isDisabled { 38 margin-top: ${SizeUnit(1 / 16)}; 39 } 40 41 /* smaller margin-left since the star icon takes up space */ 42 margin-left: ${SizeUnit(0.25)}; 43 margin-right: ${SizeUnit(0.5)}; 44 display: flex; 45 46 ${StarResourceButtonRoot} { 47 margin-right: ${SizeUnit(1.0 / 12)}; 48 } 49 50 /* groupViewIndent is used to indent un-grouped 51 items so they align with grouped items */ 52 &.groupViewIndent { 53 margin-left: ${SizeUnit(2 / 3)}; 54 } 55 ` 56 // Shared styles between the enabled and disabled item boxes 57 const sidebarItemBoxMixin = ` 58 border-radius: ${overviewItemBorderRadius}; 59 cursor: pointer; 60 display: flex; 61 flex-grow: 1; 62 font-size: ${FontSize.small}; 63 transition: color ${AnimDuration.default} linear, 64 background-color ${AnimDuration.default} linear; 65 overflow: hidden; 66 text-decoration: none; 67 ` 68 69 export let SidebarItemBox = styled.div` 70 ${sidebarItemBoxMixin}; 71 background-color: ${Color.gray30}; 72 border: 1px solid ${Color.gray40}; 73 color: ${Color.white}; 74 font-family: ${Font.monospace}; 75 position: relative; /* Anchor the .isBuilding::after pseudo-element */ 76 77 &:hover { 78 background-color: ${ColorRGBA(Color.gray30, ColorAlpha.translucent)}; 79 } 80 81 &.isSelected { 82 background-color: ${Color.white}; 83 color: ${Color.gray30}; 84 } 85 86 &.isBuilding::after { 87 content: ""; 88 position: absolute; 89 pointer-events: none; 90 width: 100%; 91 top: 0; 92 bottom: 0; 93 background: repeating-linear-gradient( 94 225deg, 95 ${ColorRGBA(Color.gray50, ColorAlpha.translucent)}, 96 ${ColorRGBA(Color.gray50, ColorAlpha.translucent)} 1px, 97 ${ColorRGBA(Color.black, 0)} 1px, 98 ${ColorRGBA(Color.black, 0)} 6px 99 ); 100 background-size: 200% 200%; 101 animation: ${barberpole} 8s linear infinite; 102 z-index: 0; 103 } 104 ` 105 106 const DisabledSidebarItemBox = styled.div` 107 ${sidebarItemBoxMixin}; 108 color: ${Color.gray50}; 109 font-family: ${Font.sansSerif}; 110 font-style: italic; 111 padding: ${SizeUnit(1 / 8)} ${SizeUnit(1 / 4)}; 112 113 &:hover { 114 color: ${Color.blue}; 115 } 116 117 &.isSelected { 118 background-color: ${Color.gray70}; 119 color: ${Color.gray10}; 120 transition: color ${AnimDuration.default} linear, 121 font-weight ${AnimDuration.default} linear; 122 font-weight: normal; 123 } 124 ` 125 126 // Flexbox (column) containing: 127 // - `SidebarItemRuntimeBox` - (row) with runtime status, name, star, timeago 128 // - `SidebarItemBuildBox` - (row) with build status, text 129 let SidebarItemInnerBox = styled.div` 130 display: flex; 131 flex-direction: column; 132 flex-grow: 1; 133 // To truncate long resource names… 134 min-width: 0; // Override default, so width can be less than content 135 ` 136 137 let SidebarItemRuntimeBox = styled.div` 138 display: flex; 139 flex-grow: 1; 140 align-items: stretch; 141 height: ${SizeUnit(1)}; 142 border-bottom: 1px solid ${Color.gray40}; 143 box-sizing: border-box; 144 transition: border-color ${AnimDuration.default} linear; 145 146 .isSelected & { 147 border-bottom-color: ${Color.grayLightest}; 148 } 149 ` 150 151 let SidebarItemBuildBox = styled.div` 152 display: flex; 153 align-items: stretch; 154 padding-right: 4px; 155 ` 156 let SidebarItemText = styled.div` 157 ${mixinTruncateText}; 158 align-items: center; 159 flex-grow: 1; 160 padding-top: 4px; 161 padding-bottom: 4px; 162 color: ${Color.grayLightest}; 163 ` 164 165 export let SidebarItemNameRoot = styled.div` 166 display: flex; 167 align-items: center; 168 font-family: ${Font.sansSerif}; 169 font-weight: 600; 170 z-index: 1; // Appear above the .isBuilding gradient 171 // To truncate long resource names… 172 min-width: 0; // Override default, so width can be less than content 173 ` 174 let SidebarItemNameTruncate = styled.span` 175 ${mixinTruncateText} 176 ` 177 178 export function sidebarItemIsDisabled(item: SidebarItem) { 179 // Both build and runtime status are disabled when a resource 180 // is disabled, so just reference runtime status here 181 return item.runtimeStatus === ResourceStatus.Disabled 182 } 183 184 let SidebarItemName = (props: { name: string }) => { 185 // A common complaint is that long names get truncated, so we 186 // use a title prop so that the user can see the full name. 187 return ( 188 <SidebarItemNameRoot title={props.name}> 189 <SidebarItemNameTruncate>{props.name}</SidebarItemNameTruncate> 190 </SidebarItemNameRoot> 191 ) 192 } 193 194 let SidebarItemTimeAgo = styled.span` 195 opacity: ${ColorAlpha.almostOpaque}; 196 display: flex; 197 justify-content: flex-end; 198 flex-grow: 1; 199 align-items: center; 200 text-align: right; 201 white-space: nowrap; 202 padding-right: ${SizeUnit(0.25)}; 203 ` 204 205 export type SidebarItemViewProps = { 206 item: SidebarItem 207 selected: boolean 208 resourceView: ResourceView 209 pathBuilder: PathBuilder 210 groupView?: boolean 211 } 212 213 function buildStatusText(item: SidebarItem): string { 214 let buildDur = item.lastBuildDur ? formatBuildDuration(item.lastBuildDur) : "" 215 let buildStatus = item.buildStatus 216 if (buildStatus === ResourceStatus.Pending) { 217 return holdStatusText(item.hold) 218 } else if (buildStatus === ResourceStatus.Building) { 219 return "Updating…" 220 } else if (buildStatus === ResourceStatus.None) { 221 return "No update status" 222 } else if (buildStatus === ResourceStatus.Unhealthy) { 223 return "Update error" 224 } else if (buildStatus === ResourceStatus.Healthy) { 225 return `Completed in ${buildDur}` 226 } else if (buildStatus === ResourceStatus.Warning) { 227 return `Completed in ${buildDur}, with issues` 228 } 229 return "Unknown" 230 } 231 232 function holdStatusText(hold?: Hold | null): string { 233 if (!hold?.count) { 234 return "Pending" 235 } 236 237 if (hold.clusters.length) { 238 return "Waiting for cluster connection" 239 } 240 241 if (hold.images.length) { 242 return "Waiting for shared image build" 243 } 244 245 if (hold.resources.length === 1) { 246 // show the actual name 247 return `Waiting on ${hold.resources[0]}` 248 } 249 250 let count: number 251 let type: string 252 if (hold.resources.length) { 253 count = hold.resources.length 254 type = "resources" 255 } else { 256 count = hold.count 257 type = `object${hold.count > 1 ? "s" : ""}` 258 } 259 260 return `Waiting on ${count} ${type}` 261 } 262 263 function runtimeTooltipText(status: ResourceStatus): string { 264 switch (status) { 265 case ResourceStatus.Building: 266 return "Server: deploying" 267 case ResourceStatus.Pending: 268 return "Server: pending" 269 case ResourceStatus.Warning: 270 return "Server: issues" 271 case ResourceStatus.Healthy: 272 return "Server: ready" 273 case ResourceStatus.Unhealthy: 274 return "Server: unhealthy" 275 default: 276 return "No server" 277 } 278 } 279 280 function buildTooltipText(status: ResourceStatus, hold: Hold | null): string { 281 switch (status) { 282 case ResourceStatus.Building: 283 return "Update: in progress" 284 case ResourceStatus.Pending: 285 return PendingBuildDescription(hold) 286 case ResourceStatus.Warning: 287 return "Update: warning" 288 case ResourceStatus.Healthy: 289 return "Update: success" 290 case ResourceStatus.Unhealthy: 291 return "Update: error" 292 default: 293 return "No update status" 294 } 295 } 296 297 export function DisabledSidebarItemView(props: SidebarItemViewProps) { 298 const { openResource } = useResourceNav() 299 const { item, selected, groupView } = props 300 const isSelectedClass = selected ? "isSelected" : "" 301 const groupViewIndentClass = groupView ? "groupViewIndent" : "" 302 let analyticsTags = { target: item.targetType } 303 304 return ( 305 <SidebarItemRoot 306 className={`u-showStarOnHover ${isSelectedClass} ${groupViewIndentClass} isDisabled`} 307 > 308 <StarResourceButton 309 resourceName={item.name} 310 analyticsName="ui.web.sidebarStarButton" 311 analyticsTags={analyticsTags} 312 /> 313 <DisabledSidebarItemBox 314 className={`${isSelectedClass}`} 315 onClick={(_e) => openResource(item.name)} 316 role="link" 317 > 318 {item.name} 319 </DisabledSidebarItemBox> 320 </SidebarItemRoot> 321 ) 322 } 323 324 export function EnabledSidebarItemView(props: SidebarItemViewProps) { 325 let nav = useResourceNav() 326 let item = props.item 327 let formatter = timeAgoFormatter 328 let hasSuccessfullyDeployed = !isZeroTime(item.lastDeployTime) 329 let hasBuilt = item.lastBuild !== null 330 let building = !isZeroTime(item.currentBuildStartTime) 331 let time = item.lastDeployTime || "" 332 let timeAgo = <TimeAgo date={time} formatter={formatter} /> 333 let isSelected = props.selected 334 335 let isSelectedClass = isSelected ? "isSelected" : "" 336 let isBuildingClass = building ? "isBuilding" : "" 337 let onStartBuild = startBuild.bind(null, item.name) 338 const groupViewIndentClass = props.groupView ? "groupViewIndent" : "" 339 let analyticsTags = { target: item.targetType } 340 let ref: MutableRefObject<HTMLLIElement | null> = useRef(null) 341 342 useEffect(() => { 343 if (isSelected && ref.current?.scrollIntoView) { 344 ref.current.scrollIntoView({ block: "nearest" }) 345 } 346 }, [item.name, isSelected, ref]) 347 348 return ( 349 <SidebarItemRoot 350 ref={ref} 351 key={item.name} 352 className={`u-showStarOnHover u-showTriggerModeOnHover ${isSelectedClass} ${isBuildingClass} ${groupViewIndentClass}`} 353 > 354 <StarResourceButton 355 resourceName={item.name} 356 analyticsName="ui.web.sidebarStarButton" 357 analyticsTags={analyticsTags} 358 /> 359 <SidebarItemBox 360 className={`${isSelectedClass} ${isBuildingClass}`} 361 tabIndex={-1} 362 role="button" 363 onClick={(e) => nav.openResource(item.name)} 364 data-name={item.name} 365 > 366 <SidebarItemInnerBox> 367 <SidebarItemRuntimeBox> 368 <SidebarIcon 369 tooltipText={runtimeTooltipText(item.runtimeStatus)} 370 status={item.runtimeStatus} 371 /> 372 <SidebarItemName name={item.name} /> 373 <SidebarItemTimeAgo> 374 {hasSuccessfullyDeployed ? timeAgo : "—"} 375 </SidebarItemTimeAgo> 376 <SidebarBuildButton 377 isSelected={isSelected} 378 hasPendingChanges={item.hasPendingChanges} 379 hasBuilt={hasBuilt} 380 isBuilding={building} 381 triggerMode={item.triggerMode} 382 isQueued={item.queued} 383 onStartBuild={onStartBuild} 384 analyticsTags={analyticsTags} 385 stopBuildButton={item.stopBuildButton} 386 /> 387 </SidebarItemRuntimeBox> 388 <Tooltip title={buildTooltipText(item.buildStatus, item.hold)}> 389 <SidebarItemBuildBox> 390 <SidebarIcon status={item.buildStatus} /> 391 <SidebarItemText>{buildStatusText(item)}</SidebarItemText> 392 </SidebarItemBuildBox> 393 </Tooltip> 394 </SidebarItemInnerBox> 395 </SidebarItemBox> 396 </SidebarItemRoot> 397 ) 398 } 399 400 export default function SidebarItemView(props: SidebarItemViewProps) { 401 const itemIsDisabled = sidebarItemIsDisabled(props.item) 402 if (itemIsDisabled) { 403 return <DisabledSidebarItemView {...props}></DisabledSidebarItemView> 404 } else { 405 return <EnabledSidebarItemView {...props}></EnabledSidebarItemView> 406 } 407 }