Repository Public
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