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  }