< Summary

Information
Class: tui-terminal.tsx
Assembly: app.components
File(s): /home/runner/work/ClutterStock/ClutterStock/frontend/app/components/tui-terminal.tsx
Tag: 58_25416222083
Line coverage
0%
Covered lines: 0
Uncovered lines: 106
Coverable lines: 106
Total lines: 244
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 61
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/ClutterStock/ClutterStock/frontend/app/components/tui-terminal.tsx

#LineLine coverage
 1import { useEffect, useRef, useState } from "react";
 2import { useNavigate, useRouteLoaderData } from "react-router";
 3import type { ItemResponse, LocationResponse, RoomResponse } from "~/api/client";
 4import type { SessionUser } from "~/lib/session.server";
 5
 6type Line = { kind: "in" | "out" | "err" | "info"; text: string };
 7
 08const INTRO: Line[] = [
 9  { kind: "info", text: "clutter:stock terminal" },
 10  { kind: "info", text: "type 'help' for commands · 'clear' to wipe · Esc to close" },
 11];
 12
 013export function TuiTerminal({ open, onClose, items, rooms, locations, currentFilter, onFilter }: {
 14  open: boolean;
 15  onClose: () => void;
 16  items: ItemResponse[];
 17  rooms: RoomResponse[];
 18  locations: LocationResponse[];
 19  currentFilter: string;
 20  onFilter: (text: string) => void;
 21}) {
 022  const inputRef = useRef<HTMLInputElement>(null);
 023  const scrollRef = useRef<HTMLDivElement>(null);
 024  const [history, setHistory] = useState<Line[]>(INTRO);
 025  const [value, setValue] = useState("");
 026  const rootData = useRouteLoaderData("root") as { user: SessionUser | null } | undefined;
 027  const user = rootData?.user ?? null;
 028  const navigate = useNavigate();
 29
 030  useEffect(() => {
 031    if (open) {
 032      inputRef.current?.focus();
 33    }
 34  }, [open]);
 35
 036  useEffect(() => {
 037    scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
 38  }, [history]);
 39
 040  if (!open) return null;
 41
 042  function run(cmd: string) {
 043    const trimmed = cmd.trim();
 044    if (!trimmed) return;
 045    const echo: Line = { kind: "in", text: `$ ${trimmed}` };
 46
 047    if (trimmed === "clear" || trimmed === "cls") {
 048      setHistory([]);
 049      return;
 50    }
 051    if (trimmed === "exit" || trimmed === "q" || trimmed === ":q") {
 052      onClose();
 053      return;
 54    }
 55
 056    const args = trimmed.split(/\s+/);
 057    const head = args[0]!.toLowerCase();
 058    const rest = trimmed.slice(args[0]!.length).trim();
 59
 060    if (trimmed === "help" || trimmed === "?") {
 061      setHistory(h => [
 62        ...h, echo,
 63        { kind: "out", text: "available commands:" },
 64        { kind: "out", text: "  help                this list" },
 65        { kind: "out", text: "  clear / cls         wipe scrollback" },
 66        { kind: "out", text: "  exit / q / :q       close terminal" },
 67        { kind: "out", text: "  list rooms          show all rooms" },
 68        { kind: "out", text: "  list items          show all items" },
 69        { kind: "out", text: "  filter <text>       narrow visible items" },
 70        { kind: "out", text: "  filter              clear filter" },
 71        { kind: "out", text: "  whoami              show current user" },
 72        { kind: "out", text: "  logout / signout    sign out" },
 73      ]);
 074      return;
 75    }
 76
 077    if (head === "whoami") {
 078      const lines = whoami(user);
 079      setHistory(h => [...h, echo, ...lines]);
 080      return;
 81    }
 82
 083    if (head === "logout" || head === "signout") {
 084      setHistory(h => [...h, echo, { kind: "info", text: "signing out…" }]);
 085      onClose();
 86      // Defer slightly so the user sees the message before the redirect
 087      setTimeout(() => navigate("/auth/signout"), 50);
 088      return;
 89    }
 90
 091    if (head === "list") {
 092      const sub = (args[1] ?? "").toLowerCase();
 093      if (sub === "rooms") {
 094        const lines = listRooms(rooms, locations, items);
 095        setHistory(h => [...h, echo, ...lines]);
 096        return;
 97      }
 098      if (sub === "items") {
 099        const lines = listItems(items, rooms);
 0100        setHistory(h => [...h, echo, ...lines]);
 0101        return;
 102      }
 0103      setHistory(h => [...h, echo, { kind: "err", text: "usage: list rooms | list items" }]);
 0104      return;
 105    }
 106
 0107    if (head === "filter") {
 0108      onFilter(rest);
 0109      const out: Line = rest
 110        ? { kind: "out", text: `filter set: "${rest}"` }
 111        : currentFilter
 112          ? { kind: "out", text: `filter cleared (was "${currentFilter}")` }
 113          : { kind: "out", text: "filter is empty" };
 0114      setHistory(h => [...h, echo, out]);
 0115      return;
 116    }
 117
 0118    setHistory(h => [...h, echo, { kind: "err", text: `unknown command: ${args[0]}  (try 'help')` }]);
 119  }
 120
 121  // (helpers `listRooms` / `listItems` are defined below)
 0122  return (
 123    <div className="tui-terminal" role="region" aria-label="Terminal">
 124      <span className="tui-panel-title">─[ terminal ]─</span>
 125      <button
 126        type="button"
 127        onClick={onClose}
 128        className="tui-terminal-close"
 129        aria-label="Close terminal"
 130      >[x]</button>
 131
 132      <div ref={scrollRef} className="tui-terminal-history">
 0133        {history.map((line, i) => (
 0134          <div key={i} className={`tui-terminal-line tui-terminal-line--${line.kind}`}>
 135            {line.text}
 136          </div>
 137        ))}
 138      </div>
 139
 140      <form
 141        className="tui-terminal-prompt"
 0142        onSubmit={(e) => {
 0143          e.preventDefault();
 0144          run(value);
 0145          setValue("");
 146        }}
 147      >
 148        <span className="tui-terminal-sigil">$</span>
 149        <input
 150          ref={inputRef}
 151          type="text"
 152          value={value}
 0153          onChange={(e) => setValue(e.target.value)}
 0154          onKeyDown={(e) => {
 0155            if (e.key === "Escape") {
 0156              e.preventDefault();
 0157              e.stopPropagation();
 0158              onClose();
 159            }
 160          }}
 161          spellCheck={false}
 162          autoCorrect="off"
 163          autoCapitalize="off"
 164          className="tui-terminal-input"
 165          aria-label="Command"
 166        />
 167        <span className="tui-cursor">▌</span>
 168      </form>
 169    </div>
 170  );
 171}
 172
 0173function pad(s: string, width: number): string {
 0174  if (s.length >= width) return s.slice(0, width - 1) + "…";
 0175  return s + " ".repeat(width - s.length);
 176}
 177
 0178function whoami(user: SessionUser | null): Line[] {
 0179  if (!user) return [{ kind: "err", text: "(unauthenticated)" }];
 0180  const groups = (user.groups ?? []).filter(Boolean);
 0181  const rows: [string, string][] = [
 182    ["user",     user.name ?? user.preferred_username ?? user.sub],
 183    ["username", user.preferred_username ?? "(none)"],
 184    ["email",    user.email ?? "(none)"],
 185    ["groups",   groups.length > 0 ? groups.join(", ") : "(none)"],
 186    ["sub",      user.sub],
 187  ];
 0188  return rows.map(([k, v]) => ({ kind: "out", text: `${pad(k, 10)}: ${v}` }));
 189}
 190
 0191function listRooms(rooms: RoomResponse[], locations: LocationResponse[], items: ItemResponse[]): Line[] {
 0192  const locationById = new Map(locations.map(l => [l.id, l]));
 0193  const lines: Line[] = [
 194    { kind: "out", text: pad("ID", 5) + pad("LOCATION", 22) + pad("ROOM", 24) + "ITEMS" },
 195  ];
 0196  if (rooms.length === 0) {
 0197    lines.push({ kind: "out", text: "(no rooms)" });
 0198    return lines;
 199  }
 0200  for (const r of rooms) {
 0201    const loc = r.locationId != null ? locationById.get(r.locationId) : null;
 0202    const count = items.filter(i => i.roomId === r.id).length;
 0203    lines.push({
 204      kind: "out",
 205      text:
 206        pad(String(r.id ?? "—"), 5) +
 207        pad(loc?.name ?? "—", 22) +
 208        pad(r.name ?? "—", 24) +
 209        String(count),
 210    });
 211  }
 0212  lines.push({ kind: "out", text: `${rooms.length} room${rooms.length === 1 ? "" : "s"}` });
 0213  return lines;
 214}
 215
 0216function listItems(items: ItemResponse[], rooms: RoomResponse[]): Line[] {
 0217  const roomById = new Map(rooms.map(r => [r.id, r]));
 0218  const lines: Line[] = [
 219    { kind: "out", text: pad("ID", 6) + pad("NAME", 28) + pad("ROOM", 18) + "CATEGORY" },
 220  ];
 0221  if (items.length === 0) {
 0222    lines.push({ kind: "out", text: "(no items)" });
 0223    return lines;
 224  }
 225  // Cap at 200 lines to keep scrollback manageable
 0226  const max = 200;
 0227  const head = items.slice(0, max);
 0228  for (const it of head) {
 0229    const room = it.roomId != null ? roomById.get(it.roomId) : null;
 0230    lines.push({
 231      kind: "out",
 232      text:
 233        pad(String(it.id ?? "—").padStart(3, "0"), 6) +
 234        pad(it.name ?? "—", 28) +
 235        pad(room?.name ?? "—", 18) +
 236        (it.category ?? "—"),
 237    });
 238  }
 0239  if (items.length > max) {
 0240    lines.push({ kind: "out", text: `… ${items.length - max} more (use 'filter <text>' to narrow)` });
 241  }
 0242  lines.push({ kind: "out", text: `${items.length} item${items.length === 1 ? "" : "s"}` });
 0243  return lines;
 244}