Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import type { TerminalSession, UserSettings } from '@cr/shared';5import { Copy, Minus, Pencil, Plus, RefreshCw, SquarePlus, Trash2, X } from 'lucide-react';6import { saveSettings, useSettings } from '@/lib/queries';7import { closeTab, patchSession, terminateSession } from '@/lib/session-actions';8import { errorMessage } from '@/lib/api';9import { useUi } from '@/stores/ui';10import { toast } from '@/stores/toasts';11import { haptic } from '@/lib/haptic';12import { BottomSheet } from '@/components/ui/bottom-sheet';13import { ConfirmDialog } from '@/components/ui/dialog';14import { Input } from '@/components/ui/input';15import { cn } from '@/lib/cn';16import { getController } from './registry';1718const row = 'pressable flex h-[52px] w-full items-center gap-3.5 rounded-md px-3.5 text-left text-[15px] font-medium text-text active:bg-surface-2 disabled:opacity-40';1920/** Phone sheet with everything about the current terminal: rename, font, colours, copy, end. */21export function QuickActions({ open, onOpenChange, session, sessionId }: { open: boolean; onOpenChange: (o: boolean) => void; session: TerminalSession | null; sessionId: string | null }) {22 const qc = useQueryClient();23 const settings = useSettings();24 const openNew = useUi((s) => s.openNewSession);25 const [title, setTitle] = useState('');26 const [renaming, setRenaming] = useState(false);27 const [confirmEnd, setConfirmEnd] = useState(false);28 useEffect(() => {29 if (open) {30 setTitle(session?.title ?? '');31 setRenaming(false);32 }33 }, [open, session?.title]);34 const set = (patch: Partial<UserSettings>) => void saveSettings(qc, patch).catch((e) => toast.error('Could not save', errorMessage(e)));35 const font = (size: number) => set({ mobileFontSize: Math.max(8, Math.min(28, size)) });36 const rename = async () => {37 const t = title.trim();38 setRenaming(false);39 if (!session || !t || t === session.title) return;40 try {41 await patchSession(qc, session.id, { title: t });42 haptic([8, 40, 8]);43 } catch (e) {44 toast.error('Could not rename', errorMessage(e));45 }46 };47 const close = () => onOpenChange(false);48 return (49 <>50 <BottomSheet open={open} onOpenChange={onOpenChange} title={session?.title ?? 'Terminal'} testId="quick-actions">51 <div className="flex flex-col gap-4 px-4">52 <div className="rounded-md bg-surface-2 p-4">53 <div className="flex items-center justify-between">54 <span className="text-[13px] font-semibold text-text-2">Text size</span>55 <span className="tnum text-[13px] font-semibold text-text" data-testid="font-value">{settings.mobileFontSize} px</span>56 </div>57 <div className="mt-2.5 flex items-center gap-3">58 <button aria-label="Decrease font size" data-testid="font-minus" onClick={() => font(settings.mobileFontSize - 1)} className="pressable flex size-11 shrink-0 items-center justify-center rounded-full bg-surface shadow-card"><Minus className="size-4" /></button>59 <input type="range" min={8} max={28} step={1} value={settings.mobileFontSize} onChange={(e) => font(Number(e.target.value))} aria-label="Terminal font size" className="h-11 min-w-0 flex-1 accent-accent" />60 <button aria-label="Increase font size" data-testid="font-plus" onClick={() => font(settings.mobileFontSize + 1)} className="pressable flex size-11 shrink-0 items-center justify-center rounded-full bg-surface shadow-card"><Plus className="size-4" /></button>61 </div>62 <p className="mt-3 text-[13px] font-semibold text-text-2">Terminal colours</p>63 <div role="radiogroup" aria-label="Terminal colours" className="mt-2 grid grid-cols-3 gap-1 rounded-full bg-surface p-1 shadow-card">64 {(['auto', 'light', 'dark'] as const).map((t) => (65 <button key={t} role="radio" aria-checked={settings.terminalTheme === t} onClick={() => set({ terminalTheme: t })} className={cn('h-10 rounded-full text-[13px] font-medium capitalize', settings.terminalTheme === t ? 'bg-accent text-accent-fg' : 'text-muted')}>66 {t}67 </button>68 ))}69 </div>70 </div>71 <div className="flex flex-col">72 {renaming ? (73 <form className="flex items-center gap-2 px-1 py-1" onSubmit={(e) => { e.preventDefault(); void rename(); }}>74 <Input autoFocus value={title} maxLength={120} aria-label="Session title" onChange={(e) => setTitle(e.target.value)} className="h-12 rounded-full px-4" />75 <button type="submit" className="pressable h-12 shrink-0 rounded-full bg-accent px-5 text-[15px] font-semibold text-accent-fg">Save</button>76 </form>77 ) : (78 <button className={row} disabled={!session} onClick={() => setRenaming(true)}><Pencil className="size-5 text-text-2" /> Rename session</button>79 )}80 <button className={row} onClick={() => { close(); openNew(session?.projectId ?? null); }}><SquarePlus className="size-5 text-text-2" /> New session{session?.projectName ? ` in ${session.projectName}` : ''}</button>81 <button82 className={row}83 disabled={!sessionId}84 onClick={async () => {85 const text = getController(sessionId)?.visibleText() ?? '';86 try {87 await navigator.clipboard.writeText(text);88 haptic([8, 40, 8]);89 toast.success('Copied', 'The visible screen is on the clipboard.');90 } catch {91 toast.warning('Copy was blocked by the browser');92 }93 close();94 }}95 >96 <Copy className="size-5 text-text-2" /> Copy visible screen97 </button>98 <button className={row} disabled={!sessionId} onClick={() => { getController(sessionId)?.reconnectNow(); close(); }}><RefreshCw className="size-5 text-text-2" /> Reconnect</button>99 <button className={row} disabled={!sessionId} onClick={() => { if (sessionId) closeTab(sessionId); close(); }}><X className="size-5 text-text-2" /> Close tab <span className="ml-auto text-[13px] font-normal text-muted">keeps running</span></button>100 <button className={cn(row, 'text-error')} disabled={!session} onClick={() => setConfirmEnd(true)}><Trash2 className="size-5" /> Terminate session</button>101 </div>102 </div>103 </BottomSheet>104 <ConfirmDialog105 open={confirmEnd}106 onOpenChange={setConfirmEnd}107 title="Terminate session?"108 description={<>The process of “{session?.title}” will be stopped on the server. Unsaved work inside it is lost.</>}109 confirmLabel="Terminate"110 danger111 onConfirm={async () => {112 if (!session) return;113 try {114 await terminateSession(qc, session.id);115 close();116 } catch (e) {117 toast.error('Could not terminate', errorMessage(e));118 }119 }}120 />121 </>122 );123}124