1710 lines · 63.9 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";import {  Plus,  Square,  SquareSplitHorizontal,  SquareSplitVertical,  TerminalSquare,  Trash2,} from "lucide-react";import {  type ContextMenuItem,  type ResolvedKeybindingsConfig,  type ScopedThreadRef,  type ThreadId,} from "@t3tools/contracts";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import * as Schema from "effect/Schema";import {  type PointerEvent as ReactPointerEvent,  type ReactNode,  type SetStateAction,  useCallback,  useEffect,  useEffectEvent,  useMemo,  useRef,  useState,} from "react";import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";import { Button } from "~/components/ui/button";import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { cn } from "~/lib/utils";import { type TerminalContextSelection } from "~/lib/terminalContext";import {  GhosttyTerminalSurface,  type GhosttyTerminalSurfaceOptions,} from "~/terminal/ghostty/surface";import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";import { useOpenInPreferredEditor } from "../editorPreferences";import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";import {  isDiffToggleShortcut,  isTerminalClearShortcut,  isTerminalNewShortcut,  isTerminalSplitShortcut,  isTerminalSplitVerticalShortcut,  isTerminalToggleShortcut,  terminalDeleteShortcutData,  terminalNavigationShortcutData,} from "../keybindings";import {  DEFAULT_THREAD_TERMINAL_HEIGHT,  MAX_TERMINALS_PER_GROUP,  type ThreadTerminalGroup,} from "../types";import { readLocalApi } from "~/localApi";import { confirmTerminalClose } from "~/lib/terminalCloseConfirm";import { useClientSettings } from "../hooks/useSettings";import { useLocalStorage } from "../hooks/useLocalStorage";import { useAttachedTerminalSession } from "../state/terminalSessions";import { serverEnvironment } from "../state/server";import { previewEnvironment } from "../state/preview";import { terminalEnvironment } from "../state/terminal";import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";import { useAtomCommand } from "../state/use-atom-command";import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";import {  resolveTerminalFontPreference,  resolveTerminalFontSizePreference,  TYPOGRAPHY_ADVANCED_STORAGE_KEY,} from "../appearanceFonts"; const MIN_DRAWER_HEIGHT = 180;const MAX_DRAWER_HEIGHT_RATIO = 0.75;const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; function maxDrawerHeight(): number {  if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;  return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));} function clampDrawerHeight(height: number): number {  const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT;  const maxHeight = maxDrawerHeight();  return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);} function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): void {  terminal.write(`\r\n[terminal] ${message}\r\n`);} function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void {  terminal.resetAndWrite(buffer);} function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {  if (typeof document === "undefined") return fallback;   const canvas = document.createElement("canvas");  canvas.width = 1;  canvas.height = 1;  const context = canvas.getContext("2d", { willReadFrequently: true });  if (!context) return fallback;   context.clearRect(0, 0, 1, 1);  context.fillStyle = value;  context.fillRect(0, 0, 1, 1);  const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;  if (alpha === 0) return fallback;   return {    r: red ?? fallback.r,    g: green ?? fallback.g,    b: blue ?? fallback.b,  };} function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {  if (!runtimeEnv) return "";  return JSON.stringify(    Object.entries(runtimeEnv)      .filter(([key, value]) => key.length > 0 && typeof value === "string")      .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),  );} function normalizeComputedColor(value: string | null | undefined, fallback: string): string {  const normalizedValue = value?.trim().toLowerCase();  if (    !normalizedValue ||    normalizedValue === "transparent" ||    normalizedValue === "rgba(0, 0, 0, 0)" ||    normalizedValue === "rgba(0 0 0 / 0)"  ) {    return fallback;  }  return value ?? fallback;} function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string {  return normalizeComputedColor(styles.getPropertyValue(variable), fallback);} /** The surface treats an omitted family or size as "use the built-in default". */function terminalFontOptions(family: string, size: number): { family?: string; size: number } {  const trimmed = family.trim();  return trimmed.length > 0 ? { family: trimmed, size } : { size };} export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {  const isDark = document.documentElement.classList.contains("dark");  const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";  const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";  const drawerSurface =    mountElement?.closest(".thread-terminal-drawer") ??    document.querySelector(".thread-terminal-drawer") ??    document.body;  const drawerStyles = getComputedStyle(drawerSurface);  const bodyStyles = getComputedStyle(document.body);  const themeStyles = getComputedStyle(document.documentElement);  const background = normalizeComputedColor(    drawerStyles.backgroundColor,    normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),  );  const foreground = normalizeComputedColor(    drawerStyles.color,    normalizeComputedColor(bodyStyles.color, fallbackForeground),  );  const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background);  const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground);  const terminalCursor = readThemeColor(    themeStyles,    "--terminal-cursor",    isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)",  );  const terminalSelection = readThemeColor(    themeStyles,    "--terminal-selection-background",    isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)",  );  return {    background: parseTerminalColor(      terminalBackground,      isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 },    ),    foreground: parseTerminalColor(      terminalForeground,      isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 },    ),    cursor: parseTerminalColor(      terminalCursor,      isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },    ),    selectionBackground: terminalSelection,  };} export function resolveTerminalSelectionActionPosition(options: {  bounds: { left: number; top: number; width: number; height: number };  selectionRect: { right: number; bottom: number } | null;  pointer: { x: number; y: number } | null;  viewport?: { width: number; height: number } | null;}): { x: number; y: number } {  const { bounds, selectionRect, pointer, viewport } = options;  const viewportWidth =    viewport?.width ??    (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth);  const viewportHeight =    viewport?.height ??    (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight);  const drawerLeft = Math.round(bounds.left);  const drawerTop = Math.round(bounds.top);  const drawerRight = Math.round(bounds.left + bounds.width);  const drawerBottom = Math.round(bounds.top + bounds.height);  const preferredX =    selectionRect !== null      ? Math.round(selectionRect.right)      : pointer === null        ? Math.round(bounds.left + bounds.width - 140)        : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight));  const preferredY =    selectionRect !== null      ? Math.round(selectionRect.bottom + 4)      : pointer === null        ? Math.round(bounds.top + 12)        : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom));  return {    x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))),    y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))),  };} export function terminalSelectionActionDelayForClickCount(clickCount: number): number {  return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0;} export function shouldHandleTerminalSelectionMouseUp(  selectionGestureActive: boolean,  button: number,): boolean {  return selectionGestureActive && button === 0;} export function terminalSelectionLineRange(position: {  start: { y: number };  end: { y: number };}): { lineStart: number; lineEnd: number } {  const lineStart = position.start.y + 1;  return {    lineStart,    lineEnd: Math.max(lineStart, position.end.y + 1),  };} export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; /** Post-selection popup: just the two selection actions, always enabled. */export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] {  return [    { id: "add-to-chat", label: "Add to chat" },    { id: "copy", label: "Copy" },  ];} /** * Right-click menu for the terminal canvas: the selection actions (disabled * until a selection exists) plus Paste. Paste is always offered: the browser * (and Electron's default editing menu) can only paste into an editable * element, so a canvas terminal never gets a usable entry from them. */export function terminalContextMenuItems(options: {  hasSelection: boolean;}): ContextMenuItem<TerminalContextMenuAction>[] {  return [    ...terminalSelectionMenuItems().map((item) => ({      ...item,      disabled: !options.hasSelection,    })),    { id: "paste", label: "Paste" },  ];} /** * An empty selection change may only cancel a selection-action flow that is * still current: a pending popup timer, or an open popup whose request id has * not been superseded. A popup already superseded by a right-click keeps its * menu promise unsettled for a moment; treating it as active would cancel the * newer context-menu flow instead. */export function shouldClearTerminalSelectionAction(options: {  timerPending: boolean;  openMenuRequestId: number | null;  currentRequestId: number;}): boolean {  return options.timerPending || options.openMenuRequestId === options.currentRequestId;} export function shouldHandleTerminalExit(  current: TerminalSessionState["status"],  synchronized: TerminalSessionState["status"],  alreadyHandled: boolean,): boolean {  return (    (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled  );} interface TerminalViewportProps {  advancedTypography: boolean;  threadRef: ScopedThreadRef;  threadId: ThreadId;  terminalId: string;  terminalLabel: string;  cwd: string;  worktreePath?: string | null;  runtimeEnv?: Record<string, string>;  onSessionExited: () => void;  onAddTerminalContext: (selection: TerminalContextSelection) => void;  focusRequestId: number;  autoFocus: boolean;  resizeEpoch: number;  drawerHeight: number;  keybindings: ResolvedKeybindingsConfig;} interface TerminalLaunchLocation {  readonly cwd: string;  readonly worktreePath?: string | null;  readonly runtimeEnv?: Record<string, string>;} export function TerminalViewport({  advancedTypography,  threadRef,  threadId,  terminalId,  terminalLabel,  cwd,  worktreePath,  runtimeEnv,  onSessionExited,  onAddTerminalContext,  focusRequestId,  autoFocus,  resizeEpoch,  drawerHeight,  keybindings,}: TerminalViewportProps) {  const containerRef = useRef<HTMLDivElement>(null);  const terminalRef = useRef<GhosttyTerminalSurface | null>(null);  const environmentId = threadRef.environmentId;  const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));  const openInPreferredEditor = useOpenInPreferredEditor(    environmentId,    serverConfig?.availableEditors ?? [],  );  const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target));  const openPreview = useAtomCommand(previewEnvironment.open, {    reportFailure: false,  });  const runTerminalWrite = useAtomCommand(terminalEnvironment.write, {    reportFailure: false,  });  const runTerminalResize = useAtomCommand(terminalEnvironment.resize, {    reportFailure: false,  });  const hasHandledExitRef = useRef(false);  const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);  const selectionGestureActiveRef = useRef(false);  const selectionActionRequestIdRef = useRef(0);  // Holds the request id of the selection popup currently on screen, so a  // popup that was superseded (but whose menu promise has not settled yet)  // cannot be mistaken for the active flow.  const openSelectionMenuRequestIdRef = useRef<number | null>(null);  const selectionActionTimerRef = useRef<number | null>(null);  const keybindingsRef = useRef(keybindings);  const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);  const handleSessionExited = useEffectEvent(() => {    onSessionExited();  });  const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {    onAddTerminalContext(selection);  });  const readTerminalLabel = useEffectEvent(() => terminalLabel);  const terminalFontFamily = useClientSettings((settings) =>    resolveTerminalFontPreference({      advanced: advancedTypography,      code: settings.fontFamilyCode,      terminal: settings.fontFamilyTerminal,    }),  );  const terminalFontSize = useClientSettings((settings) =>    resolveTerminalFontSizePreference({      advanced: advancedTypography,      code: settings.fontSizeCode,      terminal: settings.fontSizeTerminal,    }),  );  const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });  const terminalSession = useAttachedTerminalSession({    environmentId,    terminal: {      threadId,      terminalId,      cwd,      ...(worktreePath !== undefined ? { worktreePath } : {}),      ...(runtimeEnv ? { env: runtimeEnv } : {}),    },  });  const writeTerminal = useEffectEvent((data: string) =>    runTerminalWrite({      environmentId,      input: { threadId, terminalId, data },    }),  );  const resizeTerminal = useEffectEvent((cols: number, rows: number) =>    runTerminalResize({      environmentId,      input: { threadId, terminalId, cols, rows },    }),  );  const terminalBuffer = terminalSession.buffer;  const terminalError = terminalSession.error;  const terminalStatus = terminalSession.status;  const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");  const synchronizeTerminalStatus = useEffectEvent(    (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => {      const synchronized = synchronizedStatusRef.current;      if (status === "running") {        hasHandledExitRef.current = false;      } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) {        hasHandledExitRef.current = true;        writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited");        window.setTimeout(() => {          if (hasHandledExitRef.current) {            handleSessionExited();          }        }, 0);      }      synchronizedStatusRef.current = status;    },  );  const terminalVersion = terminalSession.version;  const previousSessionRef = useRef({    buffer: terminalBuffer,    error: terminalError,    status: terminalStatus,    version: terminalVersion,  });  const latestSessionRef = useRef(previousSessionRef.current);  latestSessionRef.current = {    buffer: terminalBuffer,    error: terminalError,    status: terminalStatus,    version: terminalVersion,  };   useEffect(() => {    keybindingsRef.current = keybindings;  }, [keybindings]);   useEffect(() => {    const current = terminalFontRef.current;    if (current.family === terminalFontFamily && current.size === terminalFontSize) return;    terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize };    void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize));  }, [terminalFontFamily, terminalFontSize]);   useEffect(() => {    const mount = containerRef.current;    if (!mount) return;     const localApi = readLocalApi();    let cancelled = false;    let teardown: (() => void) | null = null;    let setupTerminal: GhosttyTerminalSurface | null = null;    let setupCleanups: Array<() => void> = [];     const setup = async (): Promise<(() => void) | null> => {      const setupFont = terminalFontRef.current;      const terminalOptions: GhosttyTerminalSurfaceOptions = {        theme: terminalThemeFromApp(mount),        font: terminalFontOptions(setupFont.family, setupFont.size),        onData: (data) => handleData(data),        onResize: (cols, rows) => void resizeTerminal(cols, rows),        onSelectionChange: () => handleSelectionChange(),        beforeKey: (event) => handleBeforeKey(event),        onLinkActivate: (text, event) => handleLinkActivate(text, event),        // The surface listens from construction, so a right-click can land        // while `create` is still awaiting WASM — before the handler below it        // exists. The ref is only assigned once that setup has run.        onContextMenu: (event) => {          if (terminalRef.current) void showTerminalContextMenu(event);        },

Lines 1–500 of 1710.