SPB Git

Repository Public

terminal-view.tsx 11.4 KB 264 lines tsx

1'use client';2import { useEffect, useMemo, useRef, useState } from 'react';3import { ArrowDown } from 'lucide-react';4import { useQueryClient } from '@tanstack/react-query';5import type { TerminalSession } from '@cr/shared';6import { saveSettings, useSessions, useSettings } from '@/lib/queries';7import { IDLE_CONN, useTerminals } from '@/stores/terminals';8import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/dropdown';9import { Button } from '@/components/ui/button';10import { Spinner } from '@/components/ui/misc';11import { Kbd } from '@/components/ui/misc';12import { toast } from '@/stores/toasts';13import { errorMessage } from '@/lib/api';14import { closeTab, replaceEndedSession, restartSession } from '@/lib/session-actions';15import { recoverableExplanation } from '@/lib/states';16import { isMac } from '@/lib/format';17import { cn } from '@/lib/cn';18import { ensureController, TERM_THEMES, type TerminalController } from './controller';19import { useTerminalTheme } from '@/hooks/use-terminal-theme';20import { attachTouch } from './touch';21import '@xterm/xterm/css/xterm.css';22import { useBrand } from '@/stores/branding';2324export interface TerminalViewProps {25  sessionId: string;26  /** Receives keyboard focus when it becomes true (desktop). */27  focused: boolean;28  mobile?: boolean;29  onFocusWithin?: () => void;30}3132export default function TerminalView({ sessionId, focused, mobile = false, onFocusWithin }: TerminalViewProps) {33  const settings = useSettings();34  const qc = useQueryClient();35  const host = useRef<HTMLDivElement>(null);36  const [ctrl, setCtrl] = useState<TerminalController | null>(null);37  const [bubble, setBubble] = useState<{ x: number; y: number } | null>(null);38  const [liveFont, setLiveFont] = useState<number | null>(null);39  const conn = useTerminals((s) => s.conns[sessionId]) ?? IDLE_CONN;40  const fontSize = liveFont ?? (mobile ? settings.mobileFontSize : settings.terminalFontSize);41  const termTheme = useTerminalTheme();42  const opts = useMemo(() => ({ fontFamily: settings.terminalFont, fontSize, scrollback: settings.scrollback, theme: termTheme }), [settings.terminalFont, fontSize, settings.scrollback, termTheme]);43  const optsRef = useRef(opts);44  optsRef.current = opts;45  const focusedRef = useRef(focused);46  focusedRef.current = focused;4748  useEffect(() => {49    const el = host.current;50    if (!el) return;51    const c = ensureController(sessionId, optsRef.current);52    setCtrl(c);53    let cancelled = false;54    void c.mount(el).then(() => {55      if (!cancelled && focusedRef.current && !mobile) c.focus();56    });57    return () => {58      cancelled = true;59      c.unmount(el);60    };61  }, [sessionId, mobile]);6263  useEffect(() => {64    ctrl?.applyOptions(opts);65  }, [ctrl, opts]);66  useEffect(() => {67    setLiveFont(null); // settings caught up with the pinch gesture68  }, [settings.mobileFontSize]);69  useEffect(() => {70    if (focused && ctrl && !mobile && conn.status === 'open') ctrl.focus();71  }, [focused, ctrl, mobile, conn.status]);7273  useEffect(() => {74    const el = host.current;75    if (!ctrl || !el || !mobile) return;76    return attachTouch(ctrl, el, {77      onSelection: setBubble,78      onFontSize: (size, final) => {79        setLiveFont(size);80        if (final) void saveSettings(qc, { mobileFontSize: size }).catch(() => undefined);81      },82    });83  }, [ctrl, mobile, qc]);8485  const copy = async () => {86    if (!(await ctrl?.copySelection())) toast.warning('Nothing was copied', 'Select text first, or allow clipboard access.');87    ctrl?.term.clearSelection();88    setBubble(null);89  };90  const paste = async () => {91    if (!(await ctrl?.pasteFromClipboard())) toast.warning('Paste blocked by the browser', `Use ${isMac() ? '⌘V' : 'Ctrl+V'} instead.`);92    ctrl?.focus();93  };9495  const surface = (96    <div97      ref={host}98      data-testid="terminal"99      data-session-id={sessionId}100      data-status={conn.status}101      onFocusCapture={onFocusWithin}102      data-term-theme={termTheme}103      style={{ background: TERM_THEMES[termTheme].background }}104      className={cn('h-full w-full overflow-hidden px-2.5 pt-2 [-webkit-touch-callout:none] md:px-3.5 md:pt-3', mobile ? 'rounded-t-lg' : 'rounded-md border border-card-line shadow-card')}105    />106  );107108  return (109    <div className={cn('relative h-full w-full min-w-0 bg-bg', !mobile && 'p-2')}>110      {mobile ? (111        surface112      ) : (113        <ContextMenu>114          <ContextMenuTrigger asChild>{surface}</ContextMenuTrigger>115          <ContextMenuContent>116            <ContextMenuItem onSelect={copy}>117              Copy <Kbd className="ml-auto">{isMac() ? '⌘C' : 'Ctrl+C'}</Kbd>118            </ContextMenuItem>119            <ContextMenuItem onSelect={paste}>120              Paste <Kbd className="ml-auto">{isMac() ? '⌘V' : 'Ctrl+V'}</Kbd>121            </ContextMenuItem>122            <ContextMenuItem onSelect={() => ctrl?.term.selectAll()}>Select all</ContextMenuItem>123            <ContextMenuSeparator />124            <ContextMenuItem125              onSelect={() => {126                ctrl?.term.clear();127                ctrl?.focus();128              }}129            >130              Clear scrollback131            </ContextMenuItem>132          </ContextMenuContent>133        </ContextMenu>134      )}135136      {bubble && (137        <button138          onPointerDown={(e) => e.preventDefault()}139          onClick={copy}140          style={{ left: Math.max(8, Math.min(bubble.x - 36, (typeof window === 'undefined' ? 400 : window.innerWidth) - 80)), top: Math.max(8, bubble.y - 64) }}141          className="anim-pop fixed z-40 h-11 rounded-md border border-line-3 bg-elevated px-4 text-sm font-medium text-text shadow-pop"142        >143          Copy144        </button>145      )}146      {conn.scrolledUp && conn.status === 'open' && (147        <button148          onPointerDown={(e) => e.preventDefault()}149          onClick={() => ctrl?.scrollToBottom()}150          aria-label={conn.unread ? 'Scroll to bottom, new output' : 'Scroll to bottom'}151          data-testid="scroll-bottom"152          data-unread={conn.unread || undefined}153          className="anim-pop pressable absolute bottom-4 right-4 z-10 flex size-11 items-center justify-center rounded-full border border-card-line bg-surface text-text shadow-float"154        >155          <ArrowDown className="size-5" aria-hidden />156          {conn.unread && <span className="absolute -right-0.5 -top-0.5 size-3 rounded-full border-2 border-surface bg-coral" aria-hidden />}157        </button>158      )}159      <BackOnline reconnects={conn.reconnects} />160      <ConnectionOverlay sessionId={sessionId} status={conn.status} detail={conn.detail} exitCode={conn.exitCode} />161    </div>162  );163}164165/** Friendly confirmation after an automatic re-attach: the screen was repainted, nothing was lost. */166function BackOnline({ reconnects }: { reconnects: number }) {167  const [shown, setShown] = useState(false);168  const seen = useRef(reconnects);169  useEffect(() => {170    if (reconnects === seen.current) return;171    seen.current = reconnects;172    setShown(true);173    const t = setTimeout(() => setShown(false), 2600);174    return () => clearTimeout(t);175  }, [reconnects]);176  if (!shown) return null;177  return (178    <div role="status" data-testid="back-online" className="anim-pop pointer-events-none absolute left-1/2 top-3 z-10 flex -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full bg-success px-3.5 py-2 text-[13px] font-semibold text-bg shadow-float">179      <svg viewBox="0 0 24 24" className="size-4" aria-hidden><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" pathLength={1} className="anim-draw" /></svg>180      Back online — caught up181    </div>182  );183}184185function ConnectionOverlay({ sessionId, status, detail, exitCode }: { sessionId: string; status: string; detail: string | null; exitCode: number | null }) {186  const brand = useBrand();187  const qc = useQueryClient();188  const live = useSessions().data;189  const archived = useSessions(true, status === 'ended' || status === 'recoverable').data;190  const session: TerminalSession | undefined = live?.find((s) => s.id === sessionId) ?? archived?.find((s) => s.id === sessionId);191  const [busy, setBusy] = useState<string | null>(null);192  const run = async (name: string, fn: () => Promise<unknown>) => {193    setBusy(name);194    try {195      await fn();196    } catch (e) {197      toast.error('Action failed', errorMessage(e));198    } finally {199      setBusy(null);200    }201  };202203  if (status === 'open' || status === 'idle') return null;204  if (status === 'connecting' || status === 'reconnecting' || status === 'offline') {205    const text = status === 'connecting' ? 'Connecting…' : status === 'offline' ? 'Offline — your session keeps running' : (detail ? `${detail} — reconnecting…` : 'Reconnecting…');206    return (207      <div role="status" data-testid="term-reconnecting" className="anim-pop pointer-events-none absolute left-1/2 top-3 z-10 flex -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-card-line bg-surface px-3.5 py-2 text-[13px] font-medium text-text-2 shadow-float">208        <Spinner className="size-3.5" />209        {text}210      </div>211    );212  }213  const card = (title: string, body: React.ReactNode, actions: React.ReactNode) => (214    <div className="anim-fade absolute inset-0 z-10 flex items-end justify-center bg-scrim p-4 backdrop-blur-[2px] sm:items-center" data-testid="term-overlay" data-kind={status}>215      <div role="alertdialog" aria-label={title} className="w-full max-w-md card p-4 shadow-pop">216        <p className="text-base font-semibold tracking-tight text-text">{title}</p>217        <div className="mt-1.5 text-[13px] leading-relaxed text-text-2">{body}</div>218        <div className="mt-4 flex flex-wrap justify-end gap-2">{actions}</div>219      </div>220    </div>221  );222  const close = (223    <Button variant="ghost" onClick={() => closeTab(sessionId)}>224      Close tab225    </Button>226  );227  if (status === 'ended') {228    const code = exitCode ?? session?.exitCode ?? null;229    return card(230      `Session ended (exit ${code ?? 'unknown'})`,231      session?.kind === 'claude'232        ? `The process has exited. Restart opens a new ${brand.short} session in the same directory; Resume continues the previous conversation in a new process.`233        : 'The process has exited. Restart opens a new shell in the same directory.',234      <>235        {close}236        {session && (237          <Button loading={busy === 'restart'} onClick={() => run('restart', () => replaceEndedSession(qc, session, false))}>238            Restart239          </Button>240        )}241        {session?.kind === 'claude' && (242          <Button variant="primary" loading={busy === 'resume'} onClick={() => run('resume', () => replaceEndedSession(qc, session, true))}>243            Resume conversation244          </Button>245        )}246      </>,247    );248  }249  if (status === 'recoverable')250    return card(251      'Session is recoverable',252      recoverableExplanation(brand),253      <>254        {close}255        <Button variant="primary" loading={busy === 'restart'} onClick={() => run('restart', () => restartSession(qc, sessionId))}>256          Restart257        </Button>258      </>,259    );260  if (status === 'notfound') return card('Session not found', 'This session no longer exists on the server.', close);261  if (status === 'forbidden') return card('Access denied', 'The server refused this terminal connection.', close);262  return card('Signed out', 'Your sign-in has expired.', <Button variant="primary" onClick={() => location.assign('/login/')}>Sign in</Button>);263}264
Files main
Menu

Theme