import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'; import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import packageJson from '../../package.json'; import { closeDb } from '../db'; import { checkForUpdate } from '../utils/update-check'; import { AddFeedOverlay } from './components/AddFeedOverlay'; import { ArticleList } from './components/ArticleList'; import { ArticleReader } from './components/ArticleReader'; import { ConfirmDialog } from './components/ConfirmDialog'; import { EditFeedOverlay } from './components/EditFeedOverlay'; import { FeedList } from './components/FeedList'; import { Header } from './components/Header'; import { HelpOverlay } from './components/HelpOverlay'; import { SearchBar } from './components/SearchBar'; import { StatusBar } from './components/StatusBar'; import { UpdateOverlay } from './components/UpdateOverlay'; import { useAutoFetch } from './hooks/useAutoFetch'; import { useDataLoader } from './hooks/useDataLoader'; import { computePanelWidths } from './layout'; import { createTuiLifecycle, isTuiShutdownError, type TuiLifecycle, TuiLifecycleContext, useTuiLifecycle, } from './lifecycle'; import { AppDispatchContext, AppStateContext } from './state/context'; import { appReducer } from './state/reducer'; import type { LayoutMode, Panel } from './state/types'; import { initialState } from './state/types'; import { colors } from './theme'; function getLayoutMode(width: number): LayoutMode { if (width >= 120) return 'wide'; if (width >= 80) return 'medium'; return 'narrow'; } const RESIZE_STEP = 2; type DragTarget = 'feed' | 'article' | null; function MainContent({ layoutMode, activePanel, feedWidth, articleWidth, onDrag, onPanelClick, }: { layoutMode: LayoutMode; activePanel: Panel; feedWidth: number; articleWidth: number; onDrag: (target: DragTarget, x: number) => void; onPanelClick: (panel: Panel) => void; }) { const dragTargetRef = useRef(null); const GRAB_TOLERANCE = 3; const handleMouseDown = useCallback( (e: { x: number }) => { const feedBorder = feedWidth; const articleBorder = feedWidth + articleWidth; const distFeed = Math.abs(e.x - feedBorder); const distArticle = layoutMode === 'wide' ? Math.abs(e.x - articleBorder) : Infinity; if (distFeed <= GRAB_TOLERANCE && distFeed <= distArticle) { dragTargetRef.current = 'feed'; } else if (distArticle <= GRAB_TOLERANCE) { dragTargetRef.current = 'article'; } else { dragTargetRef.current = null; // Click-to-focus: determine which pane was clicked if (layoutMode === 'wide') { if (e.x < feedBorder) onPanelClick('feeds'); else if (e.x < articleBorder) onPanelClick('articles'); else onPanelClick('reader'); } else if (layoutMode === 'medium') { if (e.x < feedBorder) onPanelClick('feeds'); else onPanelClick('articles'); } } }, [feedWidth, articleWidth, layoutMode, onPanelClick], ); const handleMouseDrag = useCallback( (e: { x: number }) => { if (dragTargetRef.current) { onDrag(dragTargetRef.current, e.x); } }, [onDrag], ); const handleMouseDragEnd = useCallback(() => { dragTargetRef.current = null; }, []); // Wide: 3 columns (feeds | articles | reader) if (layoutMode === 'wide') { return ( // biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes do not expose ARIA roles; this terminal container handles pane focus and resize. ); } // Medium: 2 columns (feeds | articles) or full-width reader if (layoutMode === 'medium') { if (activePanel === 'reader') { return ( ); } return ( // biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes do not expose ARIA roles; this terminal container handles pane focus and resize. ); } // Narrow: single panel at a time if (activePanel === 'feeds') { return ( ); } if (activePanel === 'reader') { return ( ); } return ( ); } function AppInner() { const { width } = useTerminalDimensions(); const lifecycle = useTuiLifecycle(); const [state, dispatch] = useReducer(appReducer, initialState); // Update layout mode on resize useEffect(() => { dispatch({ type: 'SET_LAYOUT_MODE', mode: getLayoutMode(width) }); }, [width]); // Load data from DB useDataLoader(state, dispatch); // Auto-fetch on open if the latest fetch is stale useAutoFetch(state, dispatch); // Check for a newer version on launch (skipped under tests / via env var) useEffect(() => { if (process.env.RSS_SKIP_UPDATE_CHECK === '1' || process.env.NODE_ENV === 'test') return; let cancelled = false; void lifecycle .run((signal) => checkForUpdate({ currentVersion: packageJson.version, packageName: packageJson.name, signal, }), ) .then((result) => { if (cancelled || !lifecycle.isAcceptingWork() || !result.shouldPrompt || !result.latestVersion) return; dispatch({ type: 'SET_UPDATE_INFO', info: { packageName: packageJson.name, currentVersion: result.currentVersion, latestVersion: result.latestVersion, }, }); }) .catch((error) => { if (isTuiShutdownError(error)) return; // Network failures are non-fatal — the user just won't be prompted. }); return () => { cancelled = true; }; }, [lifecycle]); const panelWidths = computePanelWidths(width, state.layoutMode, state.feedPanelWidth, state.articlePanelWidth); const { feedWidth, articleWidth, minFeedWidth, maxFeedWidth, minArticleWidth, maxArticleWidth } = panelWidths; const contextState = useMemo( () => ({ ...state, feedPanelWidth: feedWidth, articlePanelWidth: articleWidth }), [state, feedWidth, articleWidth], ); // Panel navigation order const panelOrder = useMemo( () => (state.layoutMode === 'wide' ? ['feeds', 'articles', 'reader'] : ['feeds', 'articles']), [state.layoutMode], ); const nextPanel = useCallback(() => { if (state.overlay !== 'none') return; const idx = panelOrder.indexOf(state.activePanel); const next = panelOrder[(idx + 1) % panelOrder.length] as Panel; dispatch({ type: 'SET_PANEL', panel: next }); }, [state.activePanel, state.overlay, panelOrder]); const prevPanel = useCallback(() => { if (state.overlay !== 'none') return; const idx = panelOrder.indexOf(state.activePanel); const prev = panelOrder[(idx - 1 + panelOrder.length) % panelOrder.length] as Panel; dispatch({ type: 'SET_PANEL', panel: prev }); }, [state.activePanel, state.overlay, panelOrder]); const handlePanelClick = useCallback( (panel: Panel) => { if (state.overlay !== 'none') return; if (panel !== state.activePanel) { dispatch({ type: 'SET_PANEL', panel }); } }, [state.activePanel, state.overlay], ); // Unified drag handler for pane resizing const handlePaneDrag = useCallback( (target: DragTarget, x: number) => { if (target === 'feed') { const newWidth = Math.max(minFeedWidth, Math.min(maxFeedWidth, x)); dispatch({ type: 'SET_FEED_PANEL_WIDTH', width: newWidth }); } else if (target === 'article') { const newWidth = Math.max(minArticleWidth, Math.min(maxArticleWidth, x - feedWidth)); dispatch({ type: 'SET_ARTICLE_PANEL_WIDTH', width: newWidth }); } }, [minFeedWidth, maxFeedWidth, feedWidth, minArticleWidth, maxArticleWidth], ); // Global keyboard handler useKeyboard((key) => { // Overlays consume all input if (state.overlay !== 'none') return; if (key.name === 'q' && !key.ctrl) { dispatch({ type: 'SET_CONFIRM', action: { message: 'Quit rss-ai?', onConfirm: () => lifecycle.shutdown(), }, }); return; } if (key.name === 'tab' && !key.shift) { nextPanel(); return; } if (key.name === 'tab' && key.shift) { prevPanel(); return; } // ? -> help overlay if (key.name === '?' || (key.shift && key.name === '/')) { dispatch({ type: 'SET_OVERLAY', overlay: 'help' }); return; } // / -> search if (key.name === '/' && !key.shift) { dispatch({ type: 'SET_OVERLAY', overlay: 'search' }); return; } // [ -> shrink sidebar if (key.name === '[') { const newWidth = Math.max(minFeedWidth, feedWidth - RESIZE_STEP); dispatch({ type: 'SET_FEED_PANEL_WIDTH', width: newWidth }); return; } // ] -> grow sidebar if (key.name === ']') { const newWidth = Math.min(maxFeedWidth, feedWidth + RESIZE_STEP); dispatch({ type: 'SET_FEED_PANEL_WIDTH', width: newWidth }); return; } // Layout mode switching if (key.name === '1') { dispatch({ type: 'SET_LAYOUT_MODE', mode: 'wide' }); return; } if (key.name === '2') { dispatch({ type: 'SET_LAYOUT_MODE', mode: 'medium' }); return; } if (key.name === '3') { dispatch({ type: 'SET_LAYOUT_MODE', mode: 'narrow' }); return; } }); return (
{/* Overlays */} {state.overlay === 'help' && } {state.overlay === 'search' && } {state.overlay === 'addFeed' && } {state.overlay === 'editFeed' && } {state.overlay === 'confirm' && } {state.overlay === 'update' && } ); } export function App({ lifecycle: suppliedLifecycle }: { lifecycle?: TuiLifecycle } = {}) { const renderer = useRenderer(); const fallbackLifecycleRef = useRef(null); if (!fallbackLifecycleRef.current) { fallbackLifecycleRef.current = createTuiLifecycle({ destroyRenderer: () => renderer.destroy(), closeDatabase: closeDb, }); } return ( ); }