All files / src/components/lists EnvironmentNav.tsx

0% Statements 0/45
0% Branches 0/46
0% Functions 0/8
0% Lines 0/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                                                                                                                                                                                                                                                                                       
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"]');
      if (!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 {
        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>
  );
}