All files / src/components/lists EnvironmentNav.tsx

86.66% Statements 39/45
84.09% Branches 37/44
50% Functions 4/8
88.37% Lines 38/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140                  7x                               44x 44x   44x 44x 44x 44x   44x         44x   44x             44x   16x 16x     27x   16x 16x   16x 5x 5x 11x 5x 5x 6x 3x 3x 3x 3x 3x         16x 16x   16x           44x   44x                   129x 129x 129x 129x 129x                                                                                
import { useCallback, useRef, type JSX, type KeyboardEvent } from "react";
import { Circle } from "lucide-react";
import { ICON_XS } from "../../utils/iconSize.js";
import { useMatch } from "react-router";
import type { Environment } from "../../hooks/types.js";
import { environmentUrl, NEW_ENVIRONMENT_URL, useAppNavigate } from "../../utils/navigation.js";
import styles from "./EnvironmentNav.module.scss";
 
/** Status-dot color mapping using CSS custom properties. */
const STATUS_COLORS: Record<string, string> = {
  connected: "var(--accent-green)",
  sleeping: "var(--accent-yellow)",
  error: "var(--accent-red)",
  disconnected: "var(--text-tertiary)",
  connecting: "var(--accent-blue)",
};
 
/** Props for the EnvironmentNav component. */
interface EnvironmentNavProps {
  /** List of all environments to display in the nav. */
  environments: Environment[];
}
 
/** Vertical nav rail listing environments with status dots. */
export function EnvironmentNav({ environments }: EnvironmentNavProps): JSX.Element {
  const navigate = useAppNavigate();
  const tabListRef = useRef<HTMLElement>(null);
 
  const envMatch = useMatch("/environments/:environmentId");
  const editMatch = useMatch("/environments/:environmentId/edit");
  const workspaceMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId");
  const workspaceSubMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId/*");
  const rawId =
    envMatch?.params.environmentId ??
    editMatch?.params.environmentId ??
    workspaceMatch?.params.environmentId ??
    workspaceSubMatch?.params.environmentId;
  /** Filter out the "new" pseudo-ID so /environments/new doesn't highlight a real tab. */
  const activeId = rawId === "new" ? undefined : rawId;
 
  const handleClick = useCallback(
    (envId: string) => {
      navigate(environmentUrl(envId));
    },
    [navigate],
  );
 
  const handleKeyDown = useCallback(
    (e: KeyboardEvent<HTMLElement>) => {
      const buttons = tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
      Iif (!buttons || buttons.length === 0) {
        return;
      }
      const focusedIndex = Array.from(buttons).findIndex((b) => b === document.activeElement);
      const currentIndex =
        focusedIndex >= 0 ? focusedIndex : environments.findIndex((env) => env.id === activeId);
      let nextIndex = currentIndex;
 
      if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
        e.preventDefault();
        nextIndex = (currentIndex + 1) % buttons.length;
      } else if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
        e.preventDefault();
        nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
      } else if (e.key === "Home") {
        e.preventDefault();
        nextIndex = 0;
      } else if (e.key === "End") {
        e.preventDefault();
        nextIndex = buttons.length - 1;
      } else E{
        return;
      }
 
      if (nextIndex < environments.length) {
        navigate(environmentUrl(environments[nextIndex].id));
      }
      buttons[nextIndex].focus();
    },
    [activeId, environments, navigate],
  );
 
  /** When no environment is selected, the first tab should be focusable. */
  const focusableId = activeId ?? (environments.length > 0 ? environments[0].id : undefined);
 
  return (
    <div className={styles.nav} data-testid="environment-nav">
      <nav
        ref={tabListRef}
        role="tablist"
        aria-orientation="vertical"
        aria-label="Environments"
        onKeyDown={handleKeyDown}
      >
        {environments.map((env) => {
          const isActive = env.id === activeId;
          const isFocusable = env.id === focusableId;
          const statusColor = STATUS_COLORS[env.status] || "var(--text-tertiary)";
          const isConnected = env.status === "connected";
          return (
            <button
              key={env.id}
              role="tab"
              type="button"
              aria-selected={isActive}
              tabIndex={isFocusable ? 0 : -1}
              className={`${styles.tab} ${isActive ? styles.tabActive : ""}`}
              onClick={() => handleClick(env.id)}
              data-testid="env-nav-item"
            >
              <span
                className={`${styles.statusDot} ${isConnected ? styles.pulse : ""}`}
                style={{ color: statusColor }}
                aria-hidden="true"
              >
                <Circle size={ICON_XS} fill="currentColor" />
              </span>
              <span className={styles.tabLabel} title={env.displayName || env.id}>
                {env.displayName || env.id}
              </span>
            </button>
          );
        })}
      </nav>
 
      <button
        type="button"
        className={styles.addButton}
        onClick={() => navigate(NEW_ENVIRONMENT_URL)}
        title="Add environment"
        data-testid="env-nav-add"
      >
        + Add Environment
      </button>
 
      {environments.length === 0 && <div className={styles.empty}>No environments yet.</div>}
    </div>
  );
}