/** * BatchAddModal - Modal for adding multiple .torrent files at once. * * This modal is shown when multiple .torrent files are drag-and-dropped * into the TUI. It displays all files that will be added and allows * the user to select a common download path. * * @module ui/components/BatchAddModal */ import React, { useState, useEffect, useRef } from 'react'; import { Box, Text, useInput } from 'ink'; import { basename } from 'path'; import { existsSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { colors } from '../theme/index.js'; import { Modal } from './Modal.js'; import { DirectoryBrowser } from './DirectoryBrowser.js'; function getDefaultDownloadsPath(): string { return join(homedir(), 'Downloads'); } export interface BatchAddModalProps { visible: boolean; /** List of .torrent file paths to add */ files: string[]; /** Called when user confirms adding all files */ onAdd: (files: string[], downloadPath: string) => void; /** Called when user cancels or closes the modal */ onClose: () => void; /** Default download path from settings */ defaultDownloadPath?: string; } interface FileValidation { path: string; name: string; exists: boolean; } /** * Validate that files exist and extract names */ function validateFiles(files: string[]): FileValidation[] { return files.map((path) => ({ path, name: basename(path), exists: existsSync(path), })); } export const BatchAddModal: React.FC = ({ visible, files, onAdd, onClose, defaultDownloadPath, }) => { const [downloadPath, setDownloadPath] = useState(''); const [activeField, setActiveField] = useState<'list' | 'path'>('list'); const [scrollOffset, setScrollOffset] = useState(0); const wasVisible = useRef(false); // Get the effective default path const effectiveDefaultPath = defaultDownloadPath || getDefaultDownloadsPath(); // Validate files when they change const validatedFiles = validateFiles(files); const validFiles = validatedFiles.filter((f) => f.exists); const invalidFiles = validatedFiles.filter((f) => !f.exists); const hasValidFiles = validFiles.length > 0; // Maximum files to display at once const maxVisibleFiles = 8; const totalFiles = validatedFiles.length; const visibleFiles = validatedFiles.slice( scrollOffset, scrollOffset + maxVisibleFiles ); const canScrollUp = scrollOffset > 0; const canScrollDown = scrollOffset + maxVisibleFiles < totalFiles; // Reset when modal opens useEffect(() => { if (visible && !wasVisible.current) { setDownloadPath(effectiveDefaultPath); setActiveField('list'); setScrollOffset(0); } else if (!visible && wasVisible.current) { setDownloadPath(''); setActiveField('list'); setScrollOffset(0); } wasVisible.current = visible; }, [visible, effectiveDefaultPath]); const canSubmit = hasValidFiles && downloadPath.trim().length > 0; // Handle keyboard in list view useInput( (input, key) => { // Enter to submit when valid if (key.return && canSubmit) { const validPaths = validFiles.map((f) => f.path); onAdd(validPaths, downloadPath.trim()); return; } // Navigate file list if (key.upArrow && canScrollUp) { setScrollOffset((prev) => Math.max(0, prev - 1)); return; } if (key.downArrow) { if (activeField === 'list' && canScrollDown) { setScrollOffset((prev) => Math.min(totalFiles - maxVisibleFiles, prev + 1) ); } else if ( !canScrollDown || scrollOffset + maxVisibleFiles >= totalFiles ) { // Move to path field when at bottom of list setActiveField('path'); } return; } // Escape to close if (key.escape) { onClose(); return; } }, { isActive: visible && activeField === 'list' } ); // Handle escape from path field to go back to list const handlePathEscape = () => { setActiveField('list'); }; // Handle enter from path field to submit const handlePathSubmit = () => { if (canSubmit) { const validPaths = validFiles.map((f) => f.path); onAdd(validPaths, downloadPath.trim()); } }; const modalWidth = 72; const contentWidth = modalWidth - 8; return ( {/* Dropped indicator header */} {' '} DROPPED{' '} {' '} {totalFiles} file{totalFiles !== 1 ? 's' : ''} detected {/* Scroll up indicator */} {canScrollUp && ( {' '}... {scrollOffset} more above )} {/* File list */} {visibleFiles.map((file) => ( {file.exists ? ' + ' : ' x '} {file.name.length > contentWidth - 4 ? file.name.slice(0, contentWidth - 7) + '...' : file.name} ))} {/* Scroll down indicator */} {canScrollDown && ( {' '}... {totalFiles - scrollOffset - maxVisibleFiles} more below )} {/* Invalid files warning */} {invalidFiles.length > 0 && ( {invalidFiles.length} file{invalidFiles.length !== 1 ? 's' : ''} not found (will be skipped) )} {/* Download path */} Download to: {/* Help text */} {activeField === 'list' ? ( [Enter] {' '} Add all [Down] Edit path [Esc] Cancel ) : ( Tab/Right: complete Up/Down: select Enter: add Esc: back )} ); }; export default BatchAddModal;