SPB Git

Repository Public

quick-actions.tsx 7.1 KB 124 lines tsx

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
Files main
Menu

Theme