import type { PasteEvent } from '@opentui/core'; import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { updateFeed } 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 = 'title' | 'url' | 'category'; const fieldCycle: Field[] = ['title', 'url', 'category']; const MAX_TITLE_GRAPHEMES = 512; const MAX_URL_GRAPHEMES = 4_096; const MAX_CATEGORY_GRAPHEMES = 256; export function EditFeedOverlay() { const state = useAppState(); const dispatch = useAppDispatch(); const lifecycle = useTuiLifecycle(); const renderer = useRenderer(); const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions(); const feed = state.editingFeed; const [title, setTitle] = useState(() => limitGraphemes(sanitizeTerminalText(feed?.title ?? ''), MAX_TITLE_GRAPHEMES), ); const [url, setUrl] = useState(() => limitGraphemes(sanitizeTerminalText(feed?.xmlUrl ?? ''), MAX_URL_GRAPHEMES)); const [category, setCategory] = useState(() => limitGraphemes(sanitizeTerminalText(feed?.category ?? ''), MAX_CATEGORY_GRAPHEMES), ); const [activeField, setActiveField] = useState('title'); const [error, setError] = useState(null); const [saving, setSaving] = useState(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: 14, leftRatio: 0.2, topRatio: 0.3, }); const labelWidth = Math.min(12, layout.contentWidth); const rowGap = layout.contentHeight >= 7 ? 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 === 'title') setTitle((value) => limitGraphemes(value + text, MAX_TITLE_GRAPHEMES)); else 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') { // Save is not cancellable safely once it reaches the database. if (saving) return; operationRef.current += 1; dispatch({ type: 'SET_EDITING_FEED', feed: null }); return; } if (key.name === 'tab') { setActiveField((f) => fieldCycle[(fieldCycle.indexOf(f) + 1) % fieldCycle.length] as Field); return; } if ((key.name === 'enter' || key.name === 'return') && !saving) { if (!title.trim()) { setError('Title is required'); return; } if (!url.trim()) { setError('URL is required'); return; } let normalizedUrl = url.trim(); if (!/^https?:\/\//i.test(normalizedUrl)) { normalizedUrl = `https://${normalizedUrl}`; } const urlValidation = validateUrl(normalizedUrl); if (!urlValidation.valid) { setError(urlValidation.error); return; } if (!feed) return; setSaving(true); setError(null); const updates: Parameters[1] = { title: title.trim(), category: category.trim() || undefined, }; const originalUrl = /^https?:\/\//i.test(feed.xmlUrl) ? feed.xmlUrl : `https://${feed.xmlUrl}`; if (normalizedUrl !== originalUrl) { updates.xmlUrl = normalizedUrl; } const operation = operationRef.current + 1; operationRef.current = operation; const feedId = feed.id; const criteria = getArticleLoadState(stateRef.current); const isActive = () => mountedRef.current && operationRef.current === operation && stateRef.current.overlay === 'editFeed' && stateRef.current.editingFeed?.id === feedId && lifecycle.isAcceptingWork() && isSameArticleLoadState(criteria, getArticleLoadState(stateRef.current)); void lifecycle .run(async () => { await updateFeed(feedId, updates); if (!isActive()) return; await refreshVisibleData(criteria, dispatch, { canDispatch: isActive }); if (isActive()) dispatch({ type: 'SET_EDITING_FEED', feed: null }); }) .catch((err) => { if (!isTuiShutdownError(err) && isActive()) { setError(sanitizeTerminalText(err instanceof Error ? err.message : String(err))); setSaving(false); } }); return; } if (key.name === 'backspace') { if (activeField === 'title') setTitle((value) => removeLastGrapheme(value)); else 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') { appendText(' '); return; } }); if (!feed) return null; return ( {layout.contentHeight >= 1 ? ( {truncateTerminalText('Title:', labelWidth)} {truncateTerminalText(sanitizeTerminalText(title) || '(enter title)', valueWidth)} {activeField === 'title' && terminalCellWidth(title || '(enter title)') < valueWidth ? ( {'█'} ) : null} ) : null} {layout.contentHeight >= 2 + rowGap ? ( {truncateTerminalText('URL:', labelWidth)} {truncateTerminalText(sanitizeTerminalText(url) || '(enter url)', valueWidth)} {activeField === 'url' && terminalCellWidth(url || '(enter url)') < valueWidth ? ( {'█'} ) : null} ) : null} {layout.contentHeight >= 3 + rowGap * 2 ? ( {truncateTerminalText('Category:', labelWidth)} {truncateTerminalText(sanitizeTerminalText(category) || '(optional)', valueWidth)} {activeField === 'category' && terminalCellWidth(category || '(optional)') < valueWidth ? ( {'█'} ) : null} ) : null} {layout.contentHeight >= 4 + rowGap * 3 ? ( error ? ( {truncateTerminalText(sanitizeTerminalText(error), layout.contentWidth)} ) : saving ? ( {truncateTerminalText('Saving... Esc is disabled', layout.contentWidth)} ) : ( {truncateTerminalText('Tab to switch fields, Enter to save, Esc to cancel', layout.contentWidth)} ) ) : null} ); }