import { useCallback, useEffect, useState } from 'react'; import type { Terminal } from '@xterm/xterm'; import { toast } from 'sonner'; import { chatInputsRegistry, sendToChat, terminalsRegistry, type ChatInputRegistration, } from '@/lib/events'; import { cellSize } from './useTerminalFit'; // useTerminalSelection — the touch long-press selection + word-range gesture // subsystem, the document pointer/contextmenu handlers, clipboard custom keys, // the terminal + chat-input registries, and the floating-menu actions. All of // it is independent of the WS/fit path and was kept verbatim from v1.10.4. const LONG_PRESS_MS = 500; const LONG_PRESS_TOLERANCE_PX = 10; function pointToCell( term: Terminal, container: HTMLElement, clientX: number, clientY: number, ): { col: number; bufferRow: number } { const rect = container.getBoundingClientRect(); const { w, h } = cellSize(term, container); const localX = Math.max(0, clientX - rect.left); const localY = Math.max(0, clientY - rect.top); const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1))); const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1))); const bufferRow = term.buffer.active.viewportY + screenRow; return { col, bufferRow }; } const WORD_RE = /[\w.~$\-/]+/g; function wordRangeAt(line: string, col: number): { start: number; end: number } | null { for (const m of line.matchAll(WORD_RE)) { const start = m.index ?? 0; const end = start + m[0].length; if (col >= start && col < end) return { start, end }; if (start > col) return null; } return null; } interface SelectionDeps { termRef: React.MutableRefObject; containerRef: React.MutableRefObject; sessionId: string; paneId: string; label: string; send: (text: string) => void; } export interface TerminalSelectionActions { copy: () => void; paste: () => void; selectAll: () => void; search: () => void; sendToChat: (chatId: string) => void; } export interface TerminalSelection { menu: { x: number; y: number } | null; setMenu: React.Dispatch>; searchOpen: boolean; setSearchOpen: React.Dispatch>; chatInputs: ChatInputRegistration[]; hasSelection: boolean; actions: TerminalSelectionActions; } export function useTerminalSelection({ termRef, containerRef, sessionId, paneId, label, send, }: SelectionDeps): TerminalSelection { const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [searchOpen, setSearchOpen] = useState(false); const [chatInputs, setChatInputs] = useState([]); const pasteFromClipboard = useCallback((): void => { if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') { toast.error('Paste blocked — long-press input area instead'); return; } navigator.clipboard .readText() .then((text) => { if (!text) return; send(text); }) .catch(() => { toast.error('Paste blocked — long-press input area instead'); }); }, [send]); // ============================================================ // v1.10.4 features (long-press menu, right-click, custom keys) // Kept verbatim — independent of the WS/fit path that v1.10.8c fixes. // Re-bound on session/pane change so the gesture closures reference the // recreated terminal. // ============================================================ useEffect(() => { const termInit = termRef.current; const ctrInit = containerRef.current; if (!termInit || !ctrInit) return; // Non-null bindings so the gesture closures below keep the narrowing. const term: Terminal = termInit; const ctr: HTMLDivElement = ctrInit; let disposed = false; term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown') return true; const mod = e.ctrlKey || e.metaKey; if (!mod) return true; const isC = e.key === 'c' || e.key === 'C'; const isV = e.key === 'v' || e.key === 'V'; const isF = e.key === 'f' || e.key === 'F'; if (isC) { if (term.hasSelection()) { const sel = term.getSelection(); if (sel) { navigator.clipboard.writeText(sel).catch(() => { toast.error('Clipboard write failed'); }); } return false; } return !e.shiftKey; } if (isV) { pasteFromClipboard(); return false; } if (isF) { setSearchOpen(true); return false; } return true; }); // Long-press selection + floating menu (touch). let lpTimer: ReturnType | null = null; let lpStart: { x: number; y: number } | null = null; let lpAnchor: { col: number; bufferRow: number } | null = null; let inSelection = false; function clearLp(): void { if (lpTimer !== null) { clearTimeout(lpTimer); lpTimer = null; } lpStart = null; } function selectWord(col: number, bufferRow: number): boolean { const line = term.buffer.active.getLine(bufferRow); if (!line) return false; const text = line.translateToString(true); const range = wordRangeAt(text, col); if (!range) return false; term.select(range.start, bufferRow, range.end - range.start); return true; } function extendSelection( anchor: { col: number; bufferRow: number }, to: { col: number; bufferRow: number }, ): void { const a = anchor; const b = to; let s: { col: number; row: number }; let e: { col: number; row: number }; if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) { s = { col: a.col, row: a.bufferRow }; e = { col: b.col, row: b.bufferRow }; } else { s = { col: b.col, row: b.bufferRow }; e = { col: a.col, row: a.bufferRow }; } const rowsBetween = e.row - s.row; const length = rowsBetween * term.cols + (e.col - s.col) + 1; term.select(s.col, s.row, length); } function onTouchStart(e: TouchEvent): void { if (e.touches.length !== 1) return; const t = e.touches[0]!; if ((e.target as Element | null)?.closest('[data-term-menu]')) return; lpStart = { x: t.clientX, y: t.clientY }; lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY); inSelection = false; lpTimer = setTimeout(() => { if (disposed || !lpAnchor) return; const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow); if (!ok) return; inSelection = true; setMenu({ x: t.clientX, y: Math.max(t.clientY - 50, 8) }); }, LONG_PRESS_MS); } function onTouchMove(e: TouchEvent): void { if (e.touches.length !== 1) return; const t = e.touches[0]!; if (inSelection && lpAnchor) { e.preventDefault(); const to = pointToCell(term, ctr, t.clientX, t.clientY); extendSelection(lpAnchor, to); return; } if (!lpStart) return; const dx = t.clientX - lpStart.x; const dy = t.clientY - lpStart.y; if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) { clearLp(); } } function onTouchEnd(): void { if (!inSelection) clearLp(); inSelection = false; } function onTouchCancel(): void { clearLp(); inSelection = false; } ctr.addEventListener('touchstart', onTouchStart, { passive: true }); ctr.addEventListener('touchmove', onTouchMove, { passive: false }); ctr.addEventListener('touchend', onTouchEnd, { passive: true }); ctr.addEventListener('touchcancel', onTouchCancel, { passive: true }); function onContextMenu(e: MouseEvent): void { e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY }); } ctr.addEventListener('contextmenu', onContextMenu); function onDocPointerDown(e: PointerEvent): void { const t = e.target as Element | null; if (t && t.closest('[data-term-menu]')) return; setMenu(null); } document.addEventListener('pointerdown', onDocPointerDown); return () => { disposed = true; if (lpTimer !== null) clearTimeout(lpTimer); document.removeEventListener('pointerdown', onDocPointerDown); ctr.removeEventListener('touchstart', onTouchStart); ctr.removeEventListener('touchmove', onTouchMove); ctr.removeEventListener('touchend', onTouchEnd); ctr.removeEventListener('touchcancel', onTouchCancel); ctr.removeEventListener('contextmenu', onContextMenu); // attachCustomKeyEventHandler has no disposable; term.dispose() drops it. }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, paneId, send, pasteFromClipboard]); // Terminal registry: focus / openSearch / paste hooks for keyboard shortcuts // (Session.tsx) and the pane-header paste button (Workspace.tsx). Keyed on // [paneId, label] ONLY — NOT on sessionId — so a positional `label` renumber // ("Terminal N" after a pane add/remove) refreshes the registry entry // WITHOUT tearing down + reconnecting the live terminal. The focus closure // reads termRef lazily, so it tracks a terminal recreated on a session change // without needing this effect to re-run. (v2 Phase 9 latent-bug fix: `label` // was previously in the xterm-init effect's dep array.) useEffect(() => { const unregister = terminalsRegistry.register( paneId, label, () => { try { termRef.current?.focus(); } catch { /* ignore */ } }, () => setSearchOpen(true), () => pasteFromClipboard(), ); return () => unregister(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [paneId, label, pasteFromClipboard]); // Chat-input registry: populates the floating menu's "Send to chat" submenu. // Independent of session/pane/term — subscribe once. useEffect(() => { setChatInputs(chatInputsRegistry.list()); const unsubChats = chatInputsRegistry.subscribe(() => { setChatInputs(chatInputsRegistry.list()); }); return () => unsubChats(); }, []); function actCopy(): void { const term = termRef.current; if (!term) return; const sel = term.getSelection(); if (!sel) { setMenu(null); return; } navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed')); term.clearSelection(); setMenu(null); } function actPaste(): void { const reg = terminalsRegistry.get(paneId); reg?.paste(); setMenu(null); } function actSelectAll(): void { termRef.current?.selectAll(); setMenu(null); } function actSearch(): void { setSearchOpen(true); setMenu(null); } function actSendToChat(chatId: string): void { const term = termRef.current; if (!term) return; const sel = term.getSelection(); if (!sel) { setMenu(null); return; } sendToChat.emit({ chat_id: chatId, text: sel }); setMenu(null); } const hasSelection = !!termRef.current?.getSelection(); return { menu, setMenu, searchOpen, setSearchOpen, chatInputs, hasSelection, actions: { copy: actCopy, paste: actPaste, selectAll: actSelectAll, search: actSearch, sendToChat: actSendToChat, }, }; }