zz_

app/tui/TabView.tsx

zzmq.dev@gmail.com/zz / app / tui / TabView.tsx @ b27e1ef0 Download
Ladder Summary
  • file The file defines a full‑screen, tab‑based terminal UI component built with Ink: it renders a title bar with clickable tab labels, a bordered content pane whose height adapts to the terminal size, and handles mouse (clicks, wheel) and keyboard shortcuts for switching tabs, scrolling, copying the active tab’s full text, and exiting the interface. It tracks the active tab, scroll offset, and temporary flash messages, and coordinates raw‑mode handling and cleanup on quit. **Exported symbols** - `Tab` – an interface describing each tab’s label, a content‑rendering function, and an optional full‑text getter for copy‑to‑clipboard. - `TabView` – the React functional component that implements the described tabbed terminal view.
 // A real full-screen tabbed frame: fixed to the terminal's actual size
 // (see useTerminalSize) with a bordered content pane sized to fit, so
 // nothing spills into scrollback. Left/right arrows (or h/l), 1-9, or
 // clicking a tab's label switches tabs; up/down arrows (or k/j), Page
 // Up/Down, Home/End, and the mouse wheel scroll the active tab's
 // content -- each content renderer decides what a given scroll offset
 // actually means (older entries for a log, earlier rows for a list)
 // since that differs per view. y copies the active tab's full
 // (untruncated) content to the system clipboard. q or Ctrl+C quits
 // (restoring the normal screen first).
 import React, { useEffect, useState } from "react";
 import { Box, Text, useInput, useStdin } from "ink";
 import { spawn } from "node:child_process";
 import { useTerminalSize } from "./useTerminalSize.js";
 import { useMouse } from "./useMouse.js";
 import { exitFullscreen } from "./fullscreen.js";
 import { BACKDROP, PANEL, BORDER, MUTED, PINK } from "./theme.js";
 
 export interface Tab {
  label: string;
  /** Given how many rows are available and the current scroll offset (0 = latest/top), render this tab's content sized to fit. */
  content: (maxRows: number, scrollOffset: number) => React.ReactNode;
  /** The full, untruncated text of this tab right now -- used by the "y" copy-to-clipboard key. Omit for a tab that has nothing meaningful to copy as plain text. */
  getFullText?: () => string;
 }
 
 const CHROME_ROWS = 5; // title/tab bar (1) + blank (1) + content box border (2) + footer (1)
 const TAB_BAR_ROW = 1; // the tab bar is the very first rendered line
 
 function copyToClipboard(text: string): void {
  // macOS only, matching this project's one supported platform -- pipe
  // directly into pbcopy's stdin rather than shelling through a string.
  const proc = spawn("pbcopy");
  proc.stdin.write(text);
  proc.stdin.end();
 }
 
 // Where each tab's clickable label actually sits on screen, in 1-indexed
 // terminal columns -- has to mirror the real layout below exactly
 // (title, then a 2-space gap, then each "N:label" with a 2-space
 // marginRight) since that's the only way to turn a raw click column
 // back into "which tab was that".
 function tabClickRanges(title: string, tabs: Tab[]): { start: number; end: number }[] {
  const ranges: { start: number; end: number }[] = [];
  let col = title.length + 1 + 2; // title, then the " " gap after it
  for (let i = 0; i < tabs.length; i++) {
  const label = `${i + 1}:${tabs[i].label}`;
  ranges.push({ start: col, end: col + label.length - 1 });
  col += label.length + 2; // + marginRight={2}
  }
  return ranges;
 }
 
 export function TabView({ title, tabs, onExit }: { title: string; tabs: Tab[]; onExit?: () => Promise<void> | void }) {
  const [active, setActive] = useState(0);
  const [scroll, setScroll] = useState(0);
  const [flash, setFlash] = useState<string | null>(null);
  const { isRawModeSupported } = useStdin();
  const { rows, columns } = useTerminalSize();
  const contentHeight = Math.max(3, rows - CHROME_ROWS);
  const pageSize = Math.max(1, contentHeight - 2);
 
  useEffect(() => setScroll(0), [active]);
  useEffect(() => {
  if (!flash) return;
  const id = setTimeout(() => setFlash(null), 1500);
  return () => clearTimeout(id);
  }, [flash]);
 
  const scrollUp = () => setScroll((s) => s + 1);
  const scrollDown = () => setScroll((s) => Math.max(0, s - 1));
  useMouse({
  onWheelUp: scrollUp,
  onWheelDown: scrollDown,
  onClick: (col, row) => {
  if (row !== TAB_BAR_ROW) return;
  const ranges = tabClickRanges(title, tabs);
  const clicked = ranges.findIndex((r) => col >= r.start && col <= r.end);
  if (clicked >= 0) setActive(clicked);
  },
  });
 
  useInput(
  (input, key) => {
  if (input === "q" || (key.ctrl && input === "c")) {
  // Raw mode swallows the OS-level SIGINT translation for a
  // keypress, so this is the only place Ctrl+C is ever actually
  // observed in the TUI -- any process-level exit cleanup (e.g.
  // `zz work --as <name>` revoking its session's token) has to
  // run here, not in a process.on("SIGINT", ...) handler, which
  // this keypress never triggers.
  void Promise.resolve(onExit?.()).finally(() => {
  exitFullscreen();
  process.exit(0);
  });
  return;
  }
  if (key.rightArrow || input === "l") setActive((i) => (i + 1) % tabs.length);
  else if (key.leftArrow || input === "h") setActive((i) => (i - 1 + tabs.length) % tabs.length);
  else if (key.upArrow || input === "k") scrollUp();
  else if (key.downArrow || input === "j") scrollDown();
  else if (key.pageUp) setScroll((s) => s + pageSize);
  else if (key.pageDown) setScroll((s) => Math.max(0, s - pageSize));
  else if (key.home) setScroll(Number.MAX_SAFE_INTEGER);
  else if (key.end) setScroll(0);
  else if (input === "y") {
  const getFullText = tabs[active]?.getFullText;
  if (getFullText) {
  copyToClipboard(getFullText());
  setFlash(`copied "${tabs[active].label}" tab to clipboard`);
  } else {
  setFlash(`nothing to copy on the "${tabs[active]?.label}" tab`);
  }
  } else {
  const n = Number(input);
  if (Number.isInteger(n) && n >= 1 && n <= tabs.length) setActive(n - 1);
  }
  },
  { isActive: isRawModeSupported },
  );
 
  return (
  <Box flexDirection="column" width={columns} height={rows} backgroundColor={BACKDROP}>
  <Box backgroundColor={PANEL} paddingX={1}>
  <Text bold color={PINK}>
  {title}
  </Text>
  <Text>{" "}</Text>
  {tabs.map((t, i) => (
  <Box key={t.label} marginRight={2}>
  <Text bold={i === active} color={i === active ? PINK : MUTED} underline={i === active}>
  {i + 1}:{t.label}
  </Text>
  </Box>
  ))}
  </Box>
  <Box borderStyle="round" borderColor={BORDER} flexDirection="column" height={contentHeight} overflow="hidden" paddingX={1}>
  {tabs[active]?.content(pageSize, scroll)}
  </Box>
  <Box>
  <Text color={MUTED}>
  {flash ?? (isRawModeSupported ? "←/→ or 1-9 or click: tabs ↑/↓ wheel PgUp/PgDn Home/End: scroll y: copy tab q: quit" : "(non-interactive terminal -- controls disabled)")}
  </Text>
  </Box>
  </Box>
  );
 }