diff --git a/src/components/DeleteConfirmationModal.tsx b/src/components/DeleteConfirmationModal.tsx index f7a7e6a..2ac974c 100644 --- a/src/components/DeleteConfirmationModal.tsx +++ b/src/components/DeleteConfirmationModal.tsx @@ -14,10 +14,10 @@ export const DeleteConfirmationModal: React.FC = () => { }; const handleRemoveFromList = async () => { - if (deleteModalState.downloadId) { + if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) { setIsRemoving(true); try { - await removeDownload(deleteModalState.downloadId, false); + await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, false))); } catch (error) { setErrorMessage(`Remove failed: ${String(error)}`); setIsRemoving(false); @@ -28,10 +28,10 @@ export const DeleteConfirmationModal: React.FC = () => { }; const handleDeleteFile = async () => { - if (deleteModalState.downloadId) { + if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) { setIsRemoving(true); try { - await removeDownload(deleteModalState.downloadId, true); + await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, true))); } catch (error) { setErrorMessage(`Delete failed: ${String(error)}`); setIsRemoving(false); @@ -55,7 +55,7 @@ export const DeleteConfirmationModal: React.FC = () => {
- Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive. + {`Are you sure you want to remove ${deleteModalState.downloadIds?.length && deleteModalState.downloadIds.length > 1 ? 'these ' + deleteModalState.downloadIds.length + ' items' : 'this item'} from the list? You can also choose to delete the underlying file from your hard drive.`} {errorMessage &&
{errorMessage}
}
diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index edcc2d8..c2c9451 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -13,6 +13,8 @@ interface DownloadItemProps { handleResume: (item: DownloadItemType) => void; handleDoubleClick: (item: DownloadItemType) => void; getCategoryIcon: (category: string) => React.ReactNode; + isSelected: boolean; + onClick: (e: React.MouseEvent, item: DownloadItemType) => void; } export const DownloadItem = React.memo(({ @@ -24,6 +26,8 @@ export const DownloadItem = React.memo(({ handleResume, handleDoubleClick, getCategoryIcon, + isSelected, + onClick, }) => { const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId)); const pendingOrder = useDownloadStore(state => state.pendingOrder); @@ -66,8 +70,9 @@ export const DownloadItem = React.memo(({ return (
onClick(e, download)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, id: download.id }); diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index a78d105..6e24600 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -3,7 +3,7 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; import { SidebarFilter } from './Sidebar'; -import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command } from 'lucide-react'; +import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command, ChevronRight } from 'lucide-react'; import { DownloadItem as DownloadItemComponent } from './DownloadItem'; import { invokeCommand as invoke } from '../ipc'; import { @@ -16,13 +16,15 @@ interface DownloadTableProps { } export const DownloadTable: React.FC = ({ filter }) => { - const { downloads, toggleAddModal, openDeleteModal, redownload } = useDownloadStore(); + const { downloads, queues, updateDownload, toggleAddModal, openDeleteModal, redownload } = useDownloadStore(); const { isSidebarVisible, toggleSidebar } = useSettingsStore(); const { addToast } = useToast(); const isMac = navigator.userAgent.includes('Mac'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastSelectedId, setLastSelectedId] = useState(null); const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]); const columnMinimums = [0, 58, 92, 58, 48, 112]; const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' '); @@ -95,19 +97,21 @@ export const DownloadTable: React.FC = ({ filter }) => { }; const revealDownloadFile = async (item: DownloadItem) => { - if (item.status !== 'completed') { - openProperties(item.id); - return; + let pathToReveal: string | null = null; + if (item.status === 'completed') { + pathToReveal = await getDownloadPath(item); + } else { + const settings = useSettingsStore.getState(); + pathToReveal = item.destination || await resolveCategoryDestination(settings, item.category); } - const fullPath = await getDownloadPath(item); - if (!fullPath) { + if (!pathToReveal) { openProperties(item.id); return; } try { - await invoke('reveal_in_file_manager', { path: fullPath }); + await invoke('reveal_in_file_manager', { path: pathToReveal }); } catch (error) { console.error("Failed to show in Finder:", error); showInteractionError('Could not show download in Finder', error); @@ -135,6 +139,44 @@ export const DownloadTable: React.FC = ({ filter }) => { default: return d.category === filter; } }); + const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => { + if (e.metaKey || e.ctrlKey) { + const newSelected = new Set(selectedIds); + if (newSelected.has(item.id)) { + newSelected.delete(item.id); + } else { + newSelected.add(item.id); + } + setSelectedIds(newSelected); + setLastSelectedId(item.id); + } else if (e.shiftKey && lastSelectedId) { + const currentIndex = filteredDownloads.findIndex(d => d.id === item.id); + const lastIndex = filteredDownloads.findIndex(d => d.id === lastSelectedId); + + if (currentIndex !== -1 && lastIndex !== -1) { + const start = Math.min(currentIndex, lastIndex); + const end = Math.max(currentIndex, lastIndex); + + const newSelected = new Set(selectedIds); + for (let i = start; i <= end; i++) { + newSelected.add(filteredDownloads[i].id); + } + setSelectedIds(newSelected); + } + } else { + setSelectedIds(new Set([item.id])); + setLastSelectedId(item.id); + } + }; + + const handleContextMenu = (menu: { x: number; y: number; id: string }) => { + if (!selectedIds.has(menu.id)) { + setSelectedIds(new Set([menu.id])); + setLastSelectedId(menu.id); + } + setContextMenu(menu); + }; + const getFilterTitle = () => { if (filter.startsWith('queue:')) { @@ -163,8 +205,8 @@ export const DownloadTable: React.FC = ({ filter }) => { useDownloadStore.getState().resumeDownload(item.id); }; - const handleDelete = (id: string) => { - openDeleteModal(id); + const handleDelete = (ids: string | string[]) => { + openDeleteModal(ids); }; const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null; @@ -277,11 +319,13 @@ export const DownloadTable: React.FC = ({ filter }) => { downloadId={d.id} index={index} tableGridTemplate={tableGridTemplate} - setContextMenu={setContextMenu} + setContextMenu={handleContextMenu} handlePause={handlePause} handleResume={handleResume} handleDoubleClick={handleDownloadDoubleClick} getCategoryIcon={getCategoryIcon} + isSelected={selectedIds.has(d.id)} + onClick={handleItemClick} /> ))} {Array.from({ length: Math.max(0, 50 - filteredDownloads.length) }).map((_, i) => { @@ -311,128 +355,224 @@ export const DownloadTable: React.FC = ({ filter }) => { }} onClick={(e) => e.stopPropagation()} > - {contextItem.status === 'completed' && ( - + {selectedIds.size > 1 ? ( + <> + {/* Multi-Select Context Menu */} + + +
+ +
+ +
+ {queues.map(q => ( + + ))} +
+
+ +
+ + + +
+ + + + ) : ( + <> + {/* Single-Select Context Menu */} + {contextItem.status === 'completed' && ( + + )} + + + +
+ + {(contextItem.status === 'downloading' || contextItem.status === 'queued' || contextItem.status === 'retrying') && ( + + )} + + {(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && ( + + )} + + {['completed', 'failed', 'paused'].includes(contextItem.status) && ( + + )} + + {contextItem.status !== 'completed' && ( +
+ +
+ {queues.map(q => ( + + ))} +
+
+ )} + +
+ + + + {contextItem.status === 'completed' && ( + + )} + +
+ + + +
+ + + )} - - - -
- - {(contextItem.status === 'downloading' || contextItem.status === 'queued' || contextItem.status === 'retrying') && ( - - )} - - {(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && ( - - )} - - {['completed', 'failed', 'paused'].includes(contextItem.status) && ( - - )} - -
- - - - {contextItem.status === 'completed' && ( - - )} - -
- - - -
- -
)} diff --git a/src/index.css b/src/index.css index 834831d..da2e626 100644 --- a/src/index.css +++ b/src/index.css @@ -1499,6 +1499,10 @@ .download-row:nth-child(even) { background: hsl(var(--stripe-bg)); } + + .download-row.is-selected { + background: hsl(var(--accent-color) / 0.15) !important; + } .download-row:hover { background: hsl(var(--item-hover)); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 4f38db7..aa35987 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -154,7 +154,7 @@ export type DownloadDraft = Omit void; handleExtensionDownload: (request: ExtensionDownloadRequest) => void; deleteModalState: DeleteModalState; - openDeleteModal: (downloadId?: string) => void; + openDeleteModal: (downloadIds?: string | string[]) => void; closeDeleteModal: () => void; setSelectedPropertiesDownloadId: (id: string | null) => void; addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise; @@ -258,7 +258,12 @@ export const useDownloadStore = create((set, get) => ({ activeMetadataUrl: null, parsingError: null, deleteModalState: { isOpen: false }, - openDeleteModal: (downloadId) => set({ deleteModalState: { isOpen: true, downloadId } }), + openDeleteModal: (downloadIds) => set({ + deleteModalState: { + isOpen: true, + downloadIds: Array.isArray(downloadIds) ? downloadIds : (downloadIds ? [downloadIds] : undefined) + } + }), closeDeleteModal: () => set({ deleteModalState: { isOpen: false } }), toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen,