import type { PasteEvent } from '@opentui/core'; import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { addFeed } from '../../fetchers/feed'; import { sanitizeTerminalText } from '../../utils/terminal'; import { validateUrl } from '../../utils/validation'; import { getArticleLoadState, isSameArticleLoadState, refreshVisibleData } from '../hooks/useDataLoader'; import { computeOverlayRect } from '../layout'; import { isTuiShutdownError, useTuiLifecycle } from '../lifecycle'; import { useAppDispatch, useAppState } from '../state/context'; import { limitGraphemes, removeLastGrapheme, terminalCellWidth, truncateTerminalText } from '../text'; import { colors } from '../theme'; type Field = 'url' | 'category'; const MAX_URL_GRAPHEMES = 4_096; const MAX_CATEGORY_GRAPHEMES = 256; export interface AddFeedOverlayProps { add?: typeof addFeed; refresh?: typeof refreshVisibleData; } export function getInitialFetchWarning( status: Awaited>['initialFetchStatus'], error?: string, ): string | null { switch (status) { case 'success': case 'not_modified': return null; case 'error': case 'skipped': return error ?? `initial fetch was ${status}`; case undefined: return 'initial fetch returned no outcome'; } } export function AddFeedOverlay({ add = addFeed, refresh = refreshVisibleData }: AddFeedOverlayProps = {}) { const state = useAppState(); const dispatch = useAppDispatch(); const lifecycle = useTuiLifecycle(); const renderer = useRenderer(); const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions(); const [url, setUrl] = useState(''); const [category, setCategory] = useState(''); const [activeField, setActiveField] = useState('url'); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); const [addedWithWarning, setAddedWithWarning] = useState(false); const addingRef = useRef(false); const addedWithWarningRef = useRef(false); const mountedRef = useRef(true); const operationRef = useRef(0); const stateRef = useRef(state); stateRef.current = state; const layout = computeOverlayRect(terminalWidth, terminalHeight, { widthRatio: 0.6, minWidth: 28, height: 12, leftRatio: 0.2, topRatio: 0.3, }); const labelWidth = Math.min(12, layout.contentWidth); const rowGap = layout.contentHeight >= 5 ? 1 : 0; const valueWidth = Math.max(0, layout.contentWidth - labelWidth - 1); const appendText = useCallback( (rawText: string) => { const text = sanitizeTerminalText(rawText); if (!text) return; if (activeField === 'url') setUrl((value) => limitGraphemes(value + text, MAX_URL_GRAPHEMES)); else setCategory((value) => limitGraphemes(value + text, MAX_CATEGORY_GRAPHEMES)); }, [activeField], ); useEffect(() => { const handlePaste = (event: PasteEvent) => { appendText(new TextDecoder().decode(event.bytes)); event.preventDefault(); }; renderer.keyInput.on('paste', handlePaste); return () => { renderer.keyInput.off('paste', handlePaste); }; }, [renderer, appendText]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; operationRef.current += 1; }; }, []); useKeyboard((key) => { if (key.name === 'escape') { // Cancelling a network-backed add midway leaves the created feed // ambiguous. Keep this overlay as the owner until it completes. if (addingRef.current) return; operationRef.current += 1; dispatch({ type: 'SET_OVERLAY', overlay: 'none' }); return; } // The feed already exists after a failed initial fetch. Only allow the // user to dismiss and retry it from the feed list, avoiding a duplicate add. if (addedWithWarningRef.current) return; if (key.name === 'tab') { setActiveField((f) => (f === 'url' ? 'category' : 'url')); return; } if ((key.name === 'enter' || key.name === 'return') && !adding) { if (!url.trim()) { setError('URL is required'); return; } const urlResult = validateUrl(url.trim()); if (!urlResult.valid) { setError(urlResult.error); return; } setAdding(true); addingRef.current = true; setError(null); const operation = operationRef.current + 1; operationRef.current = operation; const criteria = getArticleLoadState(stateRef.current); const isActive = () => mountedRef.current && operationRef.current === operation && stateRef.current.overlay === 'addFeed' && lifecycle.isAcceptingWork() && isSameArticleLoadState(criteria, getArticleLoadState(stateRef.current)); void lifecycle .run(async (signal) => { const result = await add(url.trim(), { category: category.trim() || undefined, fetchNow: true, signal, }); if (!isActive()) return; try { await refresh(criteria, dispatch, { canDispatch: isActive }); } catch (refreshError) { if (isActive()) { setError( sanitizeTerminalText( `Feed was saved, but the list could not refresh: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}. Press Esc, then fetch it from the feed list.`, ), ); setAddedWithWarning(true); addedWithWarningRef.current = true; setAdding(false); addingRef.current = false; } return; } if (!isActive()) return; const initialFetchWarning = getInitialFetchWarning(result.initialFetchStatus, result.initialFetchError); if (initialFetchWarning) { setError( sanitizeTerminalText( `Feed was saved, but its first fetch failed: ${initialFetchWarning}. Press Esc, then retry from the feed list.`, ), ); setAddedWithWarning(true); addedWithWarningRef.current = true; setAdding(false); addingRef.current = false; return; } if (isActive()) dispatch({ type: 'SET_OVERLAY', overlay: 'none' }); }) .catch((err) => { if (!isTuiShutdownError(err) && isActive()) { setError(sanitizeTerminalText(err instanceof Error ? err.message : String(err))); setAdding(false); addingRef.current = false; } }); return; } if (key.name === 'backspace') { if (activeField === 'url') setUrl((value) => removeLastGrapheme(value)); else setCategory((value) => removeLastGrapheme(value)); return; } // Character input. Use key.sequence (not key.name) so shifted letters keep // their case โ€” opentui lowercases key.name for them. if (!key.ctrl && !key.meta && key.name !== 'space') { appendText(key.sequence); return; } if (key.name === 'space') { if (activeField === 'category') setCategory((value) => limitGraphemes(`${value} `, MAX_CATEGORY_GRAPHEMES)); return; } }); return ( {layout.contentHeight >= 1 ? ( {truncateTerminalText('URL:', labelWidth)} {truncateTerminalText(sanitizeTerminalText(url) || '(enter feed URL)', valueWidth)} {activeField === 'url' && terminalCellWidth(url || '(enter feed URL)') < valueWidth ? ( {'โ–ˆ'} ) : null} ) : null} {layout.contentHeight >= 2 + rowGap ? ( {truncateTerminalText('Category:', labelWidth)} {truncateTerminalText(sanitizeTerminalText(category) || '(optional)', valueWidth)} {activeField === 'category' && terminalCellWidth(category || '(optional)') < valueWidth ? ( {'โ–ˆ'} ) : null} ) : null} {layout.contentHeight >= 3 + rowGap * 2 ? ( error ? ( {truncateTerminalText(sanitizeTerminalText(error), layout.contentWidth)} ) : adding ? ( {truncateTerminalText('Validating and adding feed... Esc is disabled', layout.contentWidth)} ) : addedWithWarning ? ( {truncateTerminalText('Feed saved ยท Esc to close', layout.contentWidth)} ) : ( {truncateTerminalText('Tab to switch fields, Enter to add, Esc to cancel', layout.contentWidth)} ) ) : null} ); }