All files / src index.tsx

91.21% Statements 135/148
71.79% Branches 56/78
94.44% Functions 17/18
92.06% Lines 116/126

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 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 2972x                   2x 2x 2x 2x           2x 2x     2x 19x 19x 18x     2x 2x 2x                                   2x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x     89x         89x 89x   89x 89x   89x   89x   89x   10x             89x       13x 13x 13x 13x           13x       89x 13x 13x     13x 5x 5x       8x 2x 2x 2x       6x       6x       89x 22x     22x 9x 9x 9x     9x 9x 8x 8x           9x 9x 9x   9x   1x           13x         13x 13x   13x 13x   13x         13x       13x 11x 11x 11x 2x 2x 2x 2x     13x       13x       89x 20x       89x 50x 10x         89x 20x 20x 15x 15x             89x 42x       89x 21x 4x           17x 17x   17x 17x       17x 17x   17x 17x 17x         89x 89x 2x     89x                                                                     2x 2x  
import React, {
  useState,
  useEffect,
  useRef,
  useCallback,
  KeyboardEvent,
  ChangeEvent,
  ReactNode,
  ReactElement,
} from "react";
import TerminalInput from "./linetypes/TerminalInput";
import TerminalOutput from "./linetypes/TerminalOutput";
import "./style.css";
import {
  IWindowButtonsProps,
  WindowButtons,
} from "./ui-elements/WindowButtons";
 
// Constants
const SCROLL_INTO_VIEW_DELAY_MS = 500;
const CURSOR_POSITION_OFFSET_PX = 1;
 
/** Clamps a value between a minimum and maximum */
const clamp = (value: number, min: number, max: number) => {
  Iif (value > max) return max;
  if (value < min) return min;
  return value;
};
 
export enum ColorMode {
  Light,
  Dark,
}
 
export interface Props {
  name?: string;
  prompt?: string;
  height?: string;
  colorMode?: ColorMode;
  children?: ReactNode;
  onInput?: ((input: string) => void) | null | undefined;
  startingInputValue?: string;
  passwordField?: boolean;
  redBtnCallback?: () => void;
  yellowBtnCallback?: () => void;
  greenBtnCallback?: () => void;
  TopButtonsPanel?: (props: IWindowButtonsProps) => ReactElement | null;
}
 
const Terminal = ({
  name,
  prompt,
  height = "600px",
  colorMode,
  onInput,
  children,
  startingInputValue = "",
  passwordField = false,
  redBtnCallback,
  yellowBtnCallback,
  greenBtnCallback,
  TopButtonsPanel = WindowButtons,
}: Props) => {
  // local storage key
  const terminalHistoryKey = name
    ? `terminal-history-${name}`
    : "terminal-history";
 
  // command history handling
  const [historyIndex, setHistoryIndex] = useState(-1);
  const [history, setHistory] = useState<string[]>([]);
 
  const [currentLineInput, setCurrentLineInput] = useState("");
  const [tmpInputValue, setTmpInputValue] = useState("");
 
  const [cursorPos, setCursorPos] = useState(0);
 
  const scrollIntoViewRef = useRef<HTMLDivElement>(null);
 
  const updateCurrentLineInput = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      setCurrentLineInput(event.target.value);
    },
    [],
  );
 
  // Calculates the total width in pixels of the characters to the right of the cursor.
  // Uses canvas measureText for better performance than DOM manipulation.
  const calculateInputWidth = (
    inputElement: HTMLInputElement,
    chars: string,
  ) => {
    const computedStyle = window.getComputedStyle(inputElement);
    const canvas = document.createElement("canvas");
    const context = canvas.getContext("2d");
    Iif (context) {
      context.font = `${computedStyle.fontSize} ${computedStyle.fontFamily}`;
      const width = context.measureText(chars).width;
      // Return the negative width, since the cursor position is to the left of the input suffix
      return -width;
    }
    return 0;
  };
 
  // Change index ensuring it doesn't go out of bound
  const changeHistoryIndex = (direction: 1 | -1) => {
    setHistoryIndex((oldIndex) => {
      Iif (history.length === 0) return -1;
 
      // If we're not currently looking at history (oldIndex === -1) and user presses ArrowUp, jump to the last entry.
      if (oldIndex === -1 && direction === -1) {
        setTmpInputValue(currentLineInput);
        return history.length - 1;
      }
 
      // If we're at the most recent history entry and user presses ArrowDown, go back to the temporary input value.
      if (oldIndex === history.length - 1 && direction === 1) {
        setCurrentLineInput(tmpInputValue);
        setTmpInputValue("");
        return -1;
      }
 
      // If oldIndex === -1 and direction === 1 (ArrowDown), keep -1 (nothing to go to).
      Iif (oldIndex === -1 && direction === 1) {
        return -1;
      }
 
      return clamp(oldIndex + direction, 0, history.length - 1);
    });
  };
 
  const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    Iif (!onInput) {
      return;
    }
    if (event.key === "Enter") {
      event.preventDefault();
      onInput(currentLineInput);
      setCursorPos(0);
 
      // history update
      const trimmedInput = currentLineInput.trim();
      if (trimmedInput !== "") {
        setHistory((previousHistory) =>
          previousHistory[previousHistory.length - 1] === trimmedInput
            ? previousHistory
            : [...previousHistory, trimmedInput],
        );
      }
 
      setHistoryIndex(-1);
      setCurrentLineInput("");
      setTmpInputValue("");
 
      setTimeout(
        () =>
          scrollIntoViewRef?.current?.scrollIntoView({
            behavior: "auto",
            block: "nearest",
          }),
        SCROLL_INTO_VIEW_DELAY_MS,
      );
    } else if (
      ["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Delete"].includes(
        event.key,
      )
    ) {
      const inputElement = event.currentTarget;
      let charsToRightOfCursor = "";
      let cursorIndex =
        currentLineInput.length - (inputElement.selectionStart || 0);
      cursorIndex = clamp(cursorIndex, 0, currentLineInput.length);
 
      Iif (event.key === "ArrowLeft") {
        Iif (cursorIndex > currentLineInput.length - 1) cursorIndex--;
        charsToRightOfCursor = currentLineInput.slice(
          currentLineInput.length - 1 - cursorIndex,
        );
      } else Iif (event.key === "ArrowRight" || event.key === "Delete") {
        charsToRightOfCursor = currentLineInput.slice(
          currentLineInput.length - cursorIndex + 1,
        );
      } else if (event.key === "ArrowUp") {
        event.preventDefault();
        charsToRightOfCursor = currentLineInput.slice(currentLineInput.length);
        changeHistoryIndex(-1);
      } else if (event.key === "ArrowDown") {
        event.preventDefault();
        charsToRightOfCursor = currentLineInput.slice(currentLineInput.length);
        changeHistoryIndex(1);
      }
 
      const inputWidth = calculateInputWidth(
        inputElement,
        charsToRightOfCursor,
      );
      setCursorPos(inputWidth);
    }
  };
 
  useEffect(() => {
    setCurrentLineInput(startingInputValue.trim());
  }, [startingInputValue]);
 
  // If history index changes or history length changes, we want to update the input value
  useEffect(() => {
    if (historyIndex >= 0 && historyIndex < history.length) {
      setCurrentLineInput(history[historyIndex]);
    }
  }, [historyIndex, history.length]);
 
  // history local storage persistency
  useEffect(() => {
    const storedHistory = localStorage.getItem(terminalHistoryKey);
    if (storedHistory) {
      try {
        setHistory(JSON.parse(storedHistory));
      } catch (e) {
        console.error('Failed to parse terminal history from localStorage:', e);
      }
    }
  }, [terminalHistoryKey]);
 
  useEffect(() => {
    localStorage.setItem(terminalHistoryKey, JSON.stringify(history));
  }, [terminalHistoryKey, history]);
 
  // We use a hidden input to capture terminal input; make sure the hidden input is focused when clicking anywhere on the terminal
  useEffect(() => {
    if (onInput == null) {
      return;
    }
    // keep reference to listeners so we can perform cleanup
    const elListeners: {
      terminalEl: Element;
      listener: EventListenerOrEventListenerObject;
    }[] = [];
    for (const terminalEl of document.getElementsByClassName(
      "react-terminal-wrapper",
    )) {
      const listener = () =>
        (
          terminalEl?.querySelector(".terminal-hidden-input") as HTMLElement
        )?.focus();
      terminalEl?.addEventListener("click", listener);
      elListeners.push({ terminalEl, listener });
    }
    return function cleanup() {
      elListeners.forEach((elListener) => {
        elListener.terminalEl.removeEventListener("click", elListener.listener);
      });
    };
  }, [onInput]);
 
  const classes = ["react-terminal-wrapper"];
  if (colorMode === ColorMode.Light) {
    classes.push("react-terminal-light");
  }
 
  return (
    <div className={classes.join(" ")} data-terminal-name={name}>
      <TopButtonsPanel
        {...{ redBtnCallback, yellowBtnCallback, greenBtnCallback }}
      />
      <div className="react-terminal" style={{ height }}>
        {children}
        {typeof onInput === "function" && (
          <div
            className="react-terminal-line react-terminal-input react-terminal-active-input"
            data-terminal-prompt={prompt || "$"}
            key="terminal-line-prompt"
          >
            {passwordField ? "*".repeat(currentLineInput.length) : currentLineInput}
            <span
              className="cursor"
              style={{ left: `${cursorPos + CURSOR_POSITION_OFFSET_PX}px` }}
            ></span>
          </div>
        )}
        <div ref={scrollIntoViewRef}></div>
      </div>
      <input
        className="terminal-hidden-input"
        placeholder="Terminal Hidden Input"
        value={currentLineInput}
        type={passwordField ? "password" : "text"}
        autoFocus={onInput != null}
        onChange={updateCurrentLineInput}
        onKeyDown={handleInputKeyDown}
      />
    </div>
  );
};
 
export { TerminalInput, TerminalOutput };
export default Terminal;