mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(downloads): harden queue and lifecycle synchronization
Serialize queue controls, make multi-item moves atomic, await stale enqueue cleanup, and guard late media and progress events. Add deterministic table sorting and regression coverage for worst-case lifecycle races.
This commit is contained in:
+92
-26
@@ -46,18 +46,29 @@ impl DownloadCoordinator {
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
|
||||
pub async fn register_media(&self, id: String) -> Result<watch::Receiver<bool>, String> {
|
||||
pub async fn register_media(
|
||||
&self,
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
) -> Result<watch::Receiver<bool>, String> {
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
self.media_tx
|
||||
.send(MediaCmd::Register { id, cancel_tx })
|
||||
.send(MediaCmd::Register {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
cancel_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())?;
|
||||
Ok(cancel_rx)
|
||||
}
|
||||
|
||||
pub async fn pause_media(&self, id: String) -> Result<(), String> {
|
||||
pub async fn pause_media(&self, id: String, lifecycle_generation: u64) -> Result<(), String> {
|
||||
self.media_tx
|
||||
.send(MediaCmd::Pause(id))
|
||||
.send(MediaCmd::Pause {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
@@ -65,16 +76,27 @@ impl DownloadCoordinator {
|
||||
pub async fn pause_media_with_ack(
|
||||
&self,
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<()>,
|
||||
) -> Result<(), String> {
|
||||
self.media_tx
|
||||
.send(MediaCmd::PauseWithAck(id, ack))
|
||||
.send(MediaCmd::PauseWithAck {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
ack,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
||||
}
|
||||
|
||||
pub async fn finish_media(&self, id: String) {
|
||||
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
|
||||
pub async fn finish_media(&self, id: String, lifecycle_generation: u64) {
|
||||
let _ = self
|
||||
.media_tx
|
||||
.send(MediaCmd::Finished {
|
||||
id,
|
||||
lifecycle_generation,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +118,22 @@ impl CoordinatorEventSink {
|
||||
enum MediaCmd {
|
||||
Register {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
},
|
||||
Pause(String),
|
||||
PauseWithAck(String, tokio::sync::oneshot::Sender<()>),
|
||||
Finished(String),
|
||||
Pause {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
},
|
||||
PauseWithAck {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
ack: tokio::sync::oneshot::Sender<()>,
|
||||
},
|
||||
Finished {
|
||||
id: String,
|
||||
lifecycle_generation: u64,
|
||||
},
|
||||
}
|
||||
|
||||
async fn run_coordinator(
|
||||
@@ -108,8 +141,8 @@ async fn run_coordinator(
|
||||
mut command_rx: mpsc::Receiver<DownloadCmd>,
|
||||
mut media_rx: mpsc::Receiver<MediaCmd>,
|
||||
) {
|
||||
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
|
||||
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
|
||||
let mut active_media = HashMap::<String, (u64, watch::Sender<bool>)>::new();
|
||||
let mut pending_media_acks = HashMap::<String, (u64, tokio::sync::oneshot::Sender<()>)>::new();
|
||||
let mut pending_captured_urls = Vec::<String>::new();
|
||||
let mut frontend_ready = false;
|
||||
|
||||
@@ -146,28 +179,36 @@ async fn run_coordinator(
|
||||
continue;
|
||||
};
|
||||
match command {
|
||||
MediaCmd::Register { id, cancel_tx } => {
|
||||
if let Some(previous) = active_media.insert(id, cancel_tx) {
|
||||
MediaCmd::Register { id, lifecycle_generation, cancel_tx } => {
|
||||
if let Some((_, previous)) = active_media.insert(id, (lifecycle_generation, cancel_tx)) {
|
||||
let _ = previous.send(true);
|
||||
}
|
||||
}
|
||||
MediaCmd::Pause(id) => {
|
||||
if let Some(cancel_tx) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
MediaCmd::Pause { id, lifecycle_generation } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
if let Some((_, cancel_tx)) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
MediaCmd::PauseWithAck(id, ack) => {
|
||||
if let Some(cancel_tx) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
pending_media_acks.insert(id, ack);
|
||||
MediaCmd::PauseWithAck { id, lifecycle_generation, ack } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
if let Some((_, cancel_tx)) = active_media.remove(&id) {
|
||||
let _ = cancel_tx.send(true);
|
||||
pending_media_acks.insert(id, (lifecycle_generation, ack));
|
||||
}
|
||||
} else {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
}
|
||||
MediaCmd::Finished(id) => {
|
||||
active_media.remove(&id);
|
||||
if let Some(ack) = pending_media_acks.remove(&id) {
|
||||
let _ = ack.send(());
|
||||
MediaCmd::Finished { id, lifecycle_generation } => {
|
||||
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
active_media.remove(&id);
|
||||
}
|
||||
if pending_media_acks.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
|
||||
if let Some((_, ack)) = pending_media_acks.remove(&id) {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +216,7 @@ async fn run_coordinator(
|
||||
}
|
||||
}
|
||||
|
||||
for (_, cancel_tx) in active_media {
|
||||
for (_, (_, cancel_tx)) in active_media {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
}
|
||||
@@ -251,4 +292,29 @@ mod tests {
|
||||
DownloadEvent::CapturedUrls("https://example.com/startup.zip".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_media_finish_cannot_remove_a_newer_lifecycle() {
|
||||
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
|
||||
let mut old_cancel = coordinator.register_media("same-id".to_string(), 1).await.unwrap();
|
||||
let mut new_cancel = coordinator.register_media("same-id".to_string(), 2).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), old_cancel.changed())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 1).await;
|
||||
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.pause_media_with_ack("same-id".to_string(), 2, ack_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
coordinator.finish_media("same-id".to_string(), 2).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), ack_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(*new_cancel.borrow_and_update());
|
||||
}
|
||||
}
|
||||
|
||||
+33
-4
@@ -3357,6 +3357,11 @@ async fn pause_download(
|
||||
|
||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
let media_lifecycle_generation = state
|
||||
.queue_manager
|
||||
.registered_lifecycle_generation(&id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let removed_pending = state.queue_manager.remove_from_pending(&id).await;
|
||||
|
||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||
@@ -3447,7 +3452,7 @@ async fn pause_download(
|
||||
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state
|
||||
.download_coordinator
|
||||
.pause_media_with_ack(id.clone(), tx)
|
||||
.pause_media_with_ack(id.clone(), media_lifecycle_generation, tx)
|
||||
.await?;
|
||||
} else {
|
||||
let _ = tx.send(());
|
||||
@@ -3498,6 +3503,7 @@ async fn resume_download(
|
||||
"paused" => {
|
||||
let control_epoch = state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||
state.queue_manager.allow_aria2_retries(&id).await;
|
||||
state.queue_manager.reset_aria2_retry_strikes(&id).await;
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
@@ -3713,6 +3719,11 @@ async fn remove_download(
|
||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
let media_lifecycle_generation = state
|
||||
.queue_manager
|
||||
.registered_lifecycle_generation(&id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
state.queue_manager.remove_from_pending(&id).await;
|
||||
|
||||
state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||
@@ -3748,7 +3759,7 @@ async fn remove_download(
|
||||
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state
|
||||
.download_coordinator
|
||||
.pause_media_with_ack(id.clone(), tx)
|
||||
.pause_media_with_ack(id.clone(), media_lifecycle_generation, tx)
|
||||
.await?;
|
||||
} else {
|
||||
let _ = tx.send(());
|
||||
@@ -3864,6 +3875,11 @@ async fn detach_download_for_reconfigure(
|
||||
log::info!("detach_download_for_reconfigure called for id: {}", id);
|
||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
let media_lifecycle_generation = state
|
||||
.queue_manager
|
||||
.registered_lifecycle_generation(&id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
state.queue_manager.remove_from_pending(&id).await;
|
||||
state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||
state.queue_manager.cancel_aria2_retries(&id).await;
|
||||
@@ -3908,7 +3924,7 @@ async fn detach_download_for_reconfigure(
|
||||
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state
|
||||
.download_coordinator
|
||||
.pause_media_with_ack(id.clone(), tx)
|
||||
.pause_media_with_ack(id.clone(), media_lifecycle_generation, tx)
|
||||
.await?;
|
||||
} else {
|
||||
let _ = tx.send(()); // Fallback if no task exists
|
||||
@@ -4461,6 +4477,19 @@ async fn move_in_queue(
|
||||
.await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn move_many_in_queue(
|
||||
state: tauri::State<'_, AppState>,
|
||||
ids: Vec<String>,
|
||||
queue_id: String,
|
||||
direction: crate::ipc::QueueDirection,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
Ok(state
|
||||
.queue_manager
|
||||
.move_many_in_queue(&ids, &queue_id, direction)
|
||||
.await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_from_queue(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -6717,7 +6746,7 @@ pub fn run() {
|
||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
|
||||
detach_download_for_reconfigure,
|
||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, remove_from_queue, get_pending_order,
|
||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
|
||||
parity::create_category_directories,
|
||||
|
||||
+185
-26
@@ -73,6 +73,9 @@ pub struct QueuedTask {
|
||||
pub queue_id: String,
|
||||
pub kind: TaskKind,
|
||||
pub payload: SpawnPayload,
|
||||
/// Frontend lifecycle generation that owns this sidecar and its permit.
|
||||
/// Manual internal/test tasks use generation 0.
|
||||
pub lifecycle_generation: u64,
|
||||
}
|
||||
|
||||
/// Args mirroring start_download / start_media_download. Kept untyped-loose
|
||||
@@ -120,17 +123,24 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
|
||||
/// Run a media download to completion. The permit is parked for the full
|
||||
/// duration; release is handled by QueueManager on the runner's exit.
|
||||
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
|
||||
async fn run_media(
|
||||
&self,
|
||||
id: &str,
|
||||
payload: &SpawnPayload,
|
||||
lifecycle_generation: u64,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// The centralized concurrency gatekeeper. One instance lives in AppState.
|
||||
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
registered_ids: Mutex<HashSet<String>>,
|
||||
registered_lifecycle_generations: Mutex<HashMap<String, u64>>,
|
||||
enqueue_cancellations: Mutex<HashMap<String, u64>>,
|
||||
enqueue_generations: Mutex<HashMap<String, u64>>,
|
||||
pending: Mutex<VecDeque<QueuedTask>>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
||||
active_permit_generations: Mutex<HashMap<String, u64>>,
|
||||
active_kinds: Mutex<HashMap<String, TaskKind>>,
|
||||
target_capacity: AtomicUsize,
|
||||
slots_to_retire: AtomicUsize,
|
||||
@@ -197,11 +207,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
) -> Self {
|
||||
Self {
|
||||
registered_ids: Mutex::new(HashSet::new()),
|
||||
registered_lifecycle_generations: Mutex::new(HashMap::new()),
|
||||
enqueue_cancellations: Mutex::new(HashMap::new()),
|
||||
enqueue_generations: Mutex::new(HashMap::new()),
|
||||
pending: Mutex::new(VecDeque::new()),
|
||||
semaphore: Arc::new(Semaphore::new(capacity)),
|
||||
active_permits: Mutex::new(HashMap::new()),
|
||||
active_permit_generations: Mutex::new(HashMap::new()),
|
||||
active_kinds: Mutex::new(HashMap::new()),
|
||||
target_capacity: AtomicUsize::new(capacity),
|
||||
slots_to_retire: AtomicUsize::new(0),
|
||||
@@ -237,6 +249,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
|
||||
pub async fn release_registered_id(&self, id: &str) {
|
||||
self.registered_ids.lock().await.remove(id);
|
||||
self.registered_lifecycle_generations.lock().await.remove(id);
|
||||
// A released lifecycle cannot be resumed by a delayed retry worker.
|
||||
// Epoch checks remain the authoritative guard; removing this marker
|
||||
// prevents terminal downloads from accumulating cancellation entries.
|
||||
@@ -247,6 +260,40 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
self.registered_ids.lock().await.contains(id)
|
||||
}
|
||||
|
||||
pub async fn registered_lifecycle_generation(&self, id: &str) -> Option<u64> {
|
||||
self.registered_lifecycle_generations
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.copied()
|
||||
}
|
||||
|
||||
async fn is_registered_generation(&self, id: &str, generation: u64) -> bool {
|
||||
self.registered_lifecycle_generations
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.copied()
|
||||
== Some(generation)
|
||||
}
|
||||
|
||||
async fn release_registered_id_for_generation(&self, id: &str, generation: u64) {
|
||||
let released = {
|
||||
let mut registered = self.registered_ids.lock().await;
|
||||
let mut generations = self.registered_lifecycle_generations.lock().await;
|
||||
if generations.get(id).copied() == Some(generation) {
|
||||
registered.remove(id);
|
||||
generations.remove(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if released {
|
||||
self.aria2_retry_cancelled.lock().await.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject an in-flight enqueue generation if a newer UI action supersedes it.
|
||||
pub async fn cancel_enqueue_generation(&self, id: &str, generation: u64) {
|
||||
let mut cancellations = self.enqueue_cancellations.lock().await;
|
||||
@@ -282,6 +329,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
return Err("Duplicate task".to_string());
|
||||
}
|
||||
registered.insert(id.to_string());
|
||||
self.registered_lifecycle_generations
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.to_string(), generation);
|
||||
generations.insert(id.to_string(), generation);
|
||||
Ok(previous_generation)
|
||||
}
|
||||
@@ -298,6 +349,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
return;
|
||||
}
|
||||
registered.remove(id);
|
||||
self.registered_lifecycle_generations.lock().await.remove(id);
|
||||
match previous_generation {
|
||||
Some(previous) => {
|
||||
generations.insert(id.to_string(), previous);
|
||||
@@ -459,6 +511,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.insert(id.to_string(), permit);
|
||||
}
|
||||
|
||||
async fn tag_permit_generation(&self, id: &str, generation: u64) {
|
||||
self.active_permit_generations
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.to_string(), generation);
|
||||
}
|
||||
|
||||
pub async fn active_kind(&self, id: &str) -> Option<TaskKind> {
|
||||
self.active_kinds.lock().await.get(id).cloned()
|
||||
}
|
||||
@@ -492,12 +551,30 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|
||||
pub async fn release_permit(&self, id: &str) {
|
||||
let removed = self.active_permits.lock().await.remove(id).is_some();
|
||||
self.active_permit_generations.lock().await.remove(id);
|
||||
self.active_kinds.lock().await.remove(id);
|
||||
if removed {
|
||||
self.notify.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
async fn release_permit_for_generation(&self, id: &str, generation: u64) {
|
||||
let removed = {
|
||||
let mut permits = self.active_permits.lock().await;
|
||||
let mut generations = self.active_permit_generations.lock().await;
|
||||
if generations.get(id).copied() == Some(generation) {
|
||||
generations.remove(id);
|
||||
permits.remove(id).is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if removed {
|
||||
self.active_kinds.lock().await.remove(id);
|
||||
self.notify.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn has_active_permit(&self, id: &str) -> bool {
|
||||
self.active_permits.lock().await.contains_key(id)
|
||||
}
|
||||
@@ -607,10 +684,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|
||||
async fn dispatch_one(self: Arc<Self>, permit: OwnedSemaphorePermit, task: QueuedTask) {
|
||||
let id = task.id.clone();
|
||||
let lifecycle_generation = task.lifecycle_generation;
|
||||
// Park the permit BEFORE spawning. Uniform parking:
|
||||
// aria2's RPC returns instantly, so the permit must outlive the
|
||||
// dispatch_one call. Media runners release on exit.
|
||||
self.park_permit(&id, permit).await;
|
||||
self.tag_permit_generation(&id, lifecycle_generation).await;
|
||||
self.active_kinds
|
||||
.lock()
|
||||
.await
|
||||
@@ -693,30 +772,55 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
let payload = task.payload.clone();
|
||||
let id_for_task = id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let outcome = this.spawner.run_media(&id_for_task, &payload).await;
|
||||
this.finish_runner(&id_for_task, outcome).await;
|
||||
let outcome = this
|
||||
.spawner
|
||||
.run_media(&id_for_task, &payload, lifecycle_generation)
|
||||
.await;
|
||||
this.finish_runner(&id_for_task, lifecycle_generation, outcome)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal handler for non-aria2 transfers. Emits state and frees the permit.
|
||||
/// Does not emit or release anything on intentional MEDIA_RUN_CANCELLED.
|
||||
/// Intentional cancellation is silent, but still releases backend ownership.
|
||||
/// Note: `id` is the frontend download UUID, which survives indefinitely as
|
||||
/// the terminal state.
|
||||
async fn finish_runner(self: Arc<Self>, id: &str, outcome: Result<(), String>) {
|
||||
async fn finish_runner(
|
||||
self: Arc<Self>,
|
||||
id: &str,
|
||||
lifecycle_generation: u64,
|
||||
outcome: Result<(), String>,
|
||||
) {
|
||||
if !self.is_registered_generation(id, lifecycle_generation).await {
|
||||
log::info!(
|
||||
"media runner [{}]: ignoring stale terminal outcome for lifecycle {}",
|
||||
id,
|
||||
lifecycle_generation
|
||||
);
|
||||
self.release_permit_for_generation(id, lifecycle_generation).await;
|
||||
return;
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
self.emit_state(id, DownloadStatus::Completed);
|
||||
self.release_registered_id(id).await;
|
||||
self.release_registered_id_for_generation(id, lifecycle_generation)
|
||||
.await;
|
||||
}
|
||||
Err(error) if error == MEDIA_RUN_CANCELLED => {
|
||||
self.release_registered_id_for_generation(id, lifecycle_generation)
|
||||
.await;
|
||||
}
|
||||
Err(error) if error == MEDIA_RUN_CANCELLED => {}
|
||||
Err(error) => {
|
||||
self.emit_failed(id, error);
|
||||
self.release_registered_id(id).await;
|
||||
self.release_registered_id_for_generation(id, lifecycle_generation)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
self.release_permit(id).await;
|
||||
self.release_permit_for_generation(id, lifecycle_generation)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn emit_failed(&self, id: &str, error: String) {
|
||||
@@ -852,6 +956,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
self.aria2_retry_cancelled.lock().await.remove(id);
|
||||
}
|
||||
|
||||
/// A user-initiated resume starts a new retry lifecycle while retaining
|
||||
/// the paused Aria2 payload/GID. Reset only the strike budget here; the
|
||||
/// payload is still needed for the same-GID resume path.
|
||||
pub async fn reset_aria2_retry_strikes(&self, id: &str) {
|
||||
self.aria2_retry_strikes.lock().await.remove(id);
|
||||
}
|
||||
|
||||
async fn finish_aria2_retry(&self, id: &str, gid: &str, retry_epoch: u64) {
|
||||
self.release_aria2_retry_inflight(id, retry_epoch).await;
|
||||
self.aria2_retrying_gids.lock().await.remove(gid);
|
||||
@@ -1276,6 +1387,20 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
id: &str,
|
||||
queue_id: &str,
|
||||
direction: QueueDirection,
|
||||
) -> Vec<String> {
|
||||
self.move_many_in_queue(&[id.to_string()], queue_id, direction)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Atomically move a selected block of pending tasks up or down. The
|
||||
/// frontend uses the same block semantics for multi-selection, so keeping
|
||||
/// the operation under one pending-list lock prevents partial RPC moves
|
||||
/// from leaving the backend in a different order than the UI.
|
||||
pub async fn move_many_in_queue(
|
||||
&self,
|
||||
ids: &[String],
|
||||
queue_id: &str,
|
||||
direction: QueueDirection,
|
||||
) -> Vec<String> {
|
||||
let mut pending = self.pending.lock().await;
|
||||
let queue_positions = pending
|
||||
@@ -1283,22 +1408,46 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.enumerate()
|
||||
.filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index))
|
||||
.collect::<Vec<_>>();
|
||||
let queue_pos = queue_positions
|
||||
let selected_positions = queue_positions
|
||||
.iter()
|
||||
.position(|index| pending[*index].id == id);
|
||||
if let Some(queue_pos) = queue_pos {
|
||||
let target = match direction {
|
||||
QueueDirection::Up => queue_pos.checked_sub(1),
|
||||
QueueDirection::Down => {
|
||||
if queue_pos + 1 < queue_positions.len() {
|
||||
Some(queue_pos + 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.enumerate()
|
||||
.filter_map(|(position, index)| ids.iter().any(|id| id == &pending[*index].id).then_some(position))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !selected_positions.is_empty() {
|
||||
let queue_tasks = queue_positions
|
||||
.iter()
|
||||
.map(|index| pending[*index].clone())
|
||||
.collect::<Vec<_>>();
|
||||
let selected_ids = ids.iter().collect::<HashSet<_>>();
|
||||
let selected_tasks = queue_tasks
|
||||
.iter()
|
||||
.filter(|task| selected_ids.contains(&task.id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let unselected_tasks = queue_tasks
|
||||
.iter()
|
||||
.filter(|task| !selected_ids.contains(&task.id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let first_selected = *selected_positions.first().unwrap();
|
||||
let last_selected = *selected_positions.last().unwrap();
|
||||
let selected_count = selected_tasks.len();
|
||||
let insert_index = match direction {
|
||||
QueueDirection::Up => first_selected.saturating_sub(1),
|
||||
QueueDirection::Down => (last_selected + 1)
|
||||
.saturating_sub(selected_count)
|
||||
.saturating_add(1)
|
||||
.min(unselected_tasks.len()),
|
||||
};
|
||||
if let Some(target) = target {
|
||||
pending.swap(queue_positions[queue_pos], queue_positions[target]);
|
||||
let mut reordered = Vec::with_capacity(queue_tasks.len());
|
||||
reordered.extend_from_slice(&unselected_tasks[..insert_index]);
|
||||
reordered.extend(selected_tasks);
|
||||
reordered.extend_from_slice(&unselected_tasks[insert_index..]);
|
||||
|
||||
for (queue_index, pending_index) in queue_positions.iter().enumerate() {
|
||||
pending[*pending_index] = reordered[queue_index].clone();
|
||||
}
|
||||
}
|
||||
pending
|
||||
@@ -1740,11 +1889,16 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
crate::ensure_aria2_gid_result("unpause", gid, &resumed)
|
||||
}
|
||||
|
||||
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(
|
||||
&self,
|
||||
id: &str,
|
||||
payload: &SpawnPayload,
|
||||
lifecycle_generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let mut cancel_rx = state
|
||||
.download_coordinator
|
||||
.register_media(id.to_string())
|
||||
.register_media(id.to_string(), lifecycle_generation)
|
||||
.await?;
|
||||
let outcome = crate::start_media_download_internal(
|
||||
self.app_handle.clone(),
|
||||
@@ -1777,7 +1931,7 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
let _ = state
|
||||
.download_coordinator
|
||||
.finish_media(id.to_string())
|
||||
.finish_media(id.to_string(), lifecycle_generation)
|
||||
.await;
|
||||
outcome.map(|_| ())
|
||||
}
|
||||
@@ -1823,6 +1977,11 @@ impl EnqueueItem {
|
||||
id,
|
||||
queue_id: self.queue_id,
|
||||
kind,
|
||||
lifecycle_generation: self
|
||||
.lifecycle_generation
|
||||
.as_deref()
|
||||
.and_then(|generation| generation.parse().ok())
|
||||
.unwrap_or_default(),
|
||||
payload: SpawnPayload {
|
||||
url: self.url,
|
||||
destination: self.destination,
|
||||
|
||||
@@ -63,7 +63,7 @@ impl SidecarSpawner for DelayedAria2Spawner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
unreachable!("media is not used by delayed aria2 tests")
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ impl SidecarSpawner for FailFirstAria2Spawner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
unreachable!("media is not used by fail-first aria2 tests")
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
@@ -131,6 +131,7 @@ fn sample_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -498,6 +499,7 @@ fn aria2_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -507,6 +509,7 @@ fn media_task(id: &str) -> QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Media,
|
||||
lifecycle_generation: 0,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -525,7 +528,7 @@ impl SidecarSpawner for FixedMediaSpawner {
|
||||
unreachable!("aria2 is not used by media terminal-state tests")
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
self.outcome.clone()
|
||||
}
|
||||
}
|
||||
@@ -590,6 +593,7 @@ async fn media_cancellation_does_not_emit_completed() {
|
||||
assert!(!statuses.iter().any(|status| status == "failed"));
|
||||
assert!(!statuses.iter().any(|status| status == "completed"));
|
||||
assert_eq!(manager.available_permits(), 1);
|
||||
assert!(!manager.is_registered("media-cancelled").await);
|
||||
|
||||
dispatcher.abort();
|
||||
}
|
||||
@@ -1079,6 +1083,28 @@ async fn move_up_down_reorders_pending() {
|
||||
assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_move_reorders_selected_items_as_one_atomic_block() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
let (mgr, _spawner) = make_manager(3);
|
||||
for id in ["a", "b", "c", "d", "e"] {
|
||||
mgr.push(sample_task(id)).await.unwrap();
|
||||
}
|
||||
|
||||
let selected = vec!["b".to_string(), "d".to_string()];
|
||||
assert_eq!(
|
||||
mgr.move_many_in_queue(&selected, "main", QueueDirection::Up)
|
||||
.await,
|
||||
vec!["b", "d", "a", "c", "e"]
|
||||
);
|
||||
assert_eq!(
|
||||
mgr.move_many_in_queue(&selected, "main", QueueDirection::Down)
|
||||
.await,
|
||||
vec!["a", "b", "d", "c", "e"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moving_one_queue_does_not_reorder_another_queue() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
+3
-7
@@ -1,4 +1,4 @@
|
||||
import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
|
||||
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
|
||||
import { schedulerCompletionState } from './utils/schedulerCompletion';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||
@@ -112,7 +112,7 @@ function App() {
|
||||
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
|
||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const queuedCount = downloads.filter(download =>
|
||||
download.status === 'queued' || download.status === 'staged'
|
||||
).length;
|
||||
@@ -124,11 +124,7 @@ function App() {
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||
const activeTransferCount = downloads.filter(download =>
|
||||
download.status === 'downloading' ||
|
||||
download.status === 'processing' ||
|
||||
download.status === 'retrying'
|
||||
).length;
|
||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const { addToast } = useToast();
|
||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
|
||||
|
||||
+125
-128
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
@@ -17,8 +17,13 @@ import {
|
||||
canStartDownload,
|
||||
startActionLabel
|
||||
} from '../utils/downloadActions';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||
import {
|
||||
sortDownloads,
|
||||
type DownloadSortColumn,
|
||||
type DownloadSortConfig
|
||||
} from '../utils/downloadTableSorting';
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
@@ -46,7 +51,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' } | null>({ column: 'Date Added', direction: 'desc' });
|
||||
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
|
||||
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
|
||||
const selectedIdsRef = useRef(selectedIds);
|
||||
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
||||
selectedIdsRef.current = selectedIds;
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
try {
|
||||
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
|
||||
@@ -110,15 +119,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
if (!isInput) {
|
||||
if ((e.key === 'a' || e.key === 'A') && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
const allIds = sortedDownloads.map(d => d.id);
|
||||
const allIds = sortedDownloadsRef.current.map(d => d.id);
|
||||
setSelectedIds(new Set(allIds));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (!activeEl || !activeEl.closest('.sidebar-inner')) {
|
||||
if (selectedIds.size > 0) {
|
||||
handleDelete(Array.from(selectedIds));
|
||||
if (selectedIdsRef.current.size > 0) {
|
||||
handleDelete(Array.from(selectedIdsRef.current));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,7 +135,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedIds]);
|
||||
}, []);
|
||||
|
||||
|
||||
const showInteractionError = (message: string, error: unknown) => {
|
||||
@@ -192,43 +201,24 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
openProperties(item.id);
|
||||
};
|
||||
|
||||
const filteredDownloads = downloads.filter((d: DownloadItem) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
const isQueueFilter = filter.startsWith('queue:');
|
||||
const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => {
|
||||
if (isQueueFilter) {
|
||||
return d.queueId === filter.replace('queue:', '') && d.status !== 'completed';
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return true;
|
||||
case 'active': return d.status === 'downloading';
|
||||
case 'active': return isTransferActiveStatus(d.status);
|
||||
case 'completed': return d.status === 'completed';
|
||||
case 'unfinished': return d.status !== 'completed';
|
||||
default: return d.category === filter;
|
||||
}
|
||||
});
|
||||
}), [downloads, filter, isQueueFilter]);
|
||||
|
||||
const parseSpeed = (speedStr?: string) => {
|
||||
if (!speedStr || speedStr === '-') return 0;
|
||||
const val = parseFloat(speedStr);
|
||||
if (speedStr.includes('KB/s')) return val * 1024;
|
||||
if (speedStr.includes('MB/s')) return val * 1024 * 1024;
|
||||
if (speedStr.includes('GB/s')) return val * 1024 * 1024 * 1024;
|
||||
return val;
|
||||
};
|
||||
|
||||
const parseEta = (etaStr?: string) => {
|
||||
if (!etaStr || etaStr === '-') return Infinity;
|
||||
let seconds = 0;
|
||||
const hours = etaStr.match(/(\d+)h/);
|
||||
const minutes = etaStr.match(/(\d+)m/);
|
||||
const secs = etaStr.match(/(\d+)s/);
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
return seconds;
|
||||
};
|
||||
|
||||
// Sort by queue position when viewing a specific queue so the visual
|
||||
// order matches the queue order and move-up/down buttons reflect reality.
|
||||
const sortedDownloads = filter.startsWith('queue:')
|
||||
// Queue views use the persisted queue order until the user explicitly sorts
|
||||
// a column. This keeps move-up/down controls truthful while still making
|
||||
// every header a working sort target.
|
||||
const sortedDownloads = useMemo(() => isQueueFilter && !queueSortConfig
|
||||
? [...filteredDownloads].sort((left, right) => {
|
||||
const leftActive = isActiveDownloadStatus(left.status) && left.status !== 'queued';
|
||||
const rightActive = isActiveDownloadStatus(right.status) && right.status !== 'queued';
|
||||
@@ -236,40 +226,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
if (leftActive && !rightActive) return -1;
|
||||
if (!leftActive && rightActive) return 1;
|
||||
|
||||
return (left.queuePosition ?? 0) - (right.queuePosition ?? 0);
|
||||
const positionComparison = (left.queuePosition ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.queuePosition ?? Number.MAX_SAFE_INTEGER);
|
||||
return positionComparison || left.id.localeCompare(right.id);
|
||||
})
|
||||
: sortConfig
|
||||
? [...filteredDownloads].sort((a, b) => {
|
||||
const aUnfinished = a.status !== 'completed';
|
||||
const bUnfinished = b.status !== 'completed';
|
||||
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
||||
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
|
||||
sortedDownloadsRef.current = sortedDownloads;
|
||||
|
||||
if (aUnfinished && !bUnfinished) return -1;
|
||||
if (!aUnfinished && bUnfinished) return 1;
|
||||
useEffect(() => {
|
||||
const visibleIds = new Set(sortedDownloads.map(download => download.id));
|
||||
setSelectedIds(current => {
|
||||
const next = new Set(Array.from(current).filter(id => visibleIds.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
setLastSelectedId(current => current && visibleIds.has(current) ? current : null);
|
||||
}, [sortedDownloads]);
|
||||
|
||||
let comparison = 0;
|
||||
switch (sortConfig.column) {
|
||||
case 'File Name':
|
||||
comparison = (a.fileName || a.url || '').localeCompare(b.fileName || b.url || '');
|
||||
break;
|
||||
case 'Size':
|
||||
comparison = parseInt(a.size || '0', 10) - parseInt(b.size || '0', 10);
|
||||
break;
|
||||
case 'Status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'Speed':
|
||||
comparison = parseSpeed(a.speed) - parseSpeed(b.speed);
|
||||
break;
|
||||
case 'ETA':
|
||||
comparison = parseEta(a.eta) - parseEta(b.eta);
|
||||
break;
|
||||
case 'Date Added':
|
||||
comparison = new Date(a.dateAdded || 0).getTime() - new Date(b.dateAdded || 0).getTime();
|
||||
break;
|
||||
}
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison;
|
||||
})
|
||||
: filteredDownloads;
|
||||
useEffect(() => {
|
||||
setQueueSortConfig(null);
|
||||
}, [filter, isQueueFilter]);
|
||||
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => {
|
||||
if (e.detail === 2) {
|
||||
handleDownloadDoubleClick(item);
|
||||
@@ -312,15 +288,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
setContextMenu(menu);
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (filter.startsWith('queue:')) return; // Disable custom sorting in queues
|
||||
setSortConfig(current => {
|
||||
if (current?.column === column) {
|
||||
if (current.direction === 'desc') return null; // Reset sort
|
||||
return { column, direction: 'desc' };
|
||||
}
|
||||
return { column, direction: 'asc' };
|
||||
});
|
||||
const handleSort = (column: DownloadSortColumn) => {
|
||||
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
|
||||
current?.column === column
|
||||
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { column, direction: 'asc' };
|
||||
|
||||
if (isQueueFilter) {
|
||||
setQueueSortConfig(update);
|
||||
} else {
|
||||
setSortConfig(current => update(current));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -356,8 +334,25 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = (item: DownloadItem) => {
|
||||
useDownloadStore.getState().resumeDownload(item.id);
|
||||
const handleResume = async (item: DownloadItem) => {
|
||||
try {
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
||||
if (!resumed) {
|
||||
throw new Error('The backend rejected the start/resume request.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resume:", error);
|
||||
showInteractionError(`Could not resume ${item.fileName}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const resumeItemsSequentially = async (items: DownloadItem[]) => {
|
||||
for (const item of items) {
|
||||
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||
if (current && canStartDownload(current.status)) {
|
||||
await handleResume(current);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (ids: string | string[]) => {
|
||||
@@ -449,9 +444,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
className="main-control-button"
|
||||
disabled={sortedDownloads.length === 0}
|
||||
onClick={() => {
|
||||
sortedDownloads
|
||||
.filter(d => canStartDownload(d.status))
|
||||
.forEach(d => handleResume(d));
|
||||
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
|
||||
}}
|
||||
title="Resume All"
|
||||
>
|
||||
@@ -497,13 +490,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`${index === 5 ? 'download-cell-right' : ''} ${filter.startsWith('queue:') ? 'opacity-70 cursor-default' : 'cursor-pointer hover:text-text-primary transition-colors'} flex items-center justify-between`}
|
||||
onClick={() => handleSort(label)}
|
||||
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
|
||||
onClick={() => handleSort(label as DownloadSortColumn)}
|
||||
>
|
||||
<div className="flex items-center gap-1 w-full h-full select-none">
|
||||
<span>{label}</span>
|
||||
{sortConfig?.column === label && (
|
||||
sortConfig.direction === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && (
|
||||
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
|
||||
? <ChevronUp size={14} />
|
||||
: <ChevronDown size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
@@ -519,19 +514,29 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{sortedDownloads.length === 0 ? (
|
||||
<div className="downloads-empty-state">
|
||||
<ArrowDownCircle aria-hidden="true" />
|
||||
<div className="downloads-empty-title">No Downloads</div>
|
||||
<div className="downloads-empty-title">
|
||||
{isQueueFilter ? 'Queue is empty' : filter === 'completed' ? 'No Completed Downloads' : 'No Downloads'}
|
||||
</div>
|
||||
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
|
||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
||||
<span className="flex items-center mx-1.5">
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||
</span>
|
||||
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||
</span>
|
||||
</span>
|
||||
to add downloads
|
||||
{isQueueFilter ? (
|
||||
'Add downloads to this queue from an item menu or the Add window.'
|
||||
) : filter === 'completed' ? (
|
||||
'Completed downloads will appear here.'
|
||||
) : (
|
||||
<>
|
||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
||||
<span className="flex items-center mx-1.5">
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||
</span>
|
||||
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||
</span>
|
||||
</span>
|
||||
to add downloads
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -576,27 +581,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
const itemsToResume = selectedDownloads.filter(d => canStartDownload(d.status));
|
||||
const itemsToPause = selectedDownloads.filter(d => canPauseDownload(d.status));
|
||||
const itemsToQueue = selectedDownloads.filter(d => d.status !== 'completed');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Multi-Select Context Menu */}
|
||||
{itemsToResume.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
if (itemsToResume.length > 0) {
|
||||
const activeQueueIds = Array.from(new Set(itemsToResume.map(i => i.queueId).filter(Boolean)));
|
||||
// We can use assignToQueue for bulk resume to the same queue
|
||||
if (activeQueueIds.length === 1 && activeQueueIds[0]) {
|
||||
void assignToQueue(itemsToResume.map(i => i.id), activeQueueIds[0]).catch(error => {
|
||||
showInteractionError('Could not bulk resume downloads', error);
|
||||
});
|
||||
} else {
|
||||
// Fallback for missing queue or multiple queues
|
||||
itemsToResume.forEach(handleResume);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
void resumeItemsSequentially(itemsToResume);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Start/Resume
|
||||
@@ -630,24 +625,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
)}
|
||||
|
||||
<div className="group relative">
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||
Add to Queue
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue(Array.from(selectedIds), q.id).catch(error => {
|
||||
showInteractionError('Could not move downloads to queue', error);
|
||||
});
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
))}
|
||||
{itemsToQueue.length > 0 && (
|
||||
<div className="group relative">
|
||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||
Add to Queue
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||
{queues.map(q => (
|
||||
<button key={q.id} onClick={() => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
|
||||
showInteractionError('Could not move downloads to queue', error);
|
||||
});
|
||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||
{q.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadS
|
||||
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { isTransferActiveStatus } from '../utils/downloads';
|
||||
|
||||
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
||||
|
||||
@@ -100,7 +101,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return downloads.length;
|
||||
case 'active': return downloads.filter(d => d.status === 'downloading').length;
|
||||
case 'active': return downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
||||
case 'completed': return downloads.filter(d => d.status === 'completed').length;
|
||||
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
|
||||
default: return downloads.filter(d => d.category === filter as DownloadCategory).length;
|
||||
@@ -330,14 +331,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
setContextMenu(null);
|
||||
void startQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: `Could not start queue: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play size={14} className="mr-2 text-text-secondary" />
|
||||
Start Queue
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { pauseQueue(contextMenu.id); setContextMenu(null); }}
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
setContextMenu(null);
|
||||
void pauseQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: `Could not pause queue: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pause size={14} className="mr-2 text-text-secondary" />
|
||||
Pause Queue
|
||||
|
||||
@@ -82,6 +82,7 @@ type CommandMap = {
|
||||
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
||||
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||
remove_from_queue: { args: { id: string }; result: boolean };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
@@ -45,4 +46,41 @@ describe('useDownloadProgressStore', () => {
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'terminal',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'completed',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'terminal',
|
||||
fraction: 0.1,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s',
|
||||
size: '1 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal',
|
||||
status: 'failed',
|
||||
error: 'stale failure'
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,10 +52,15 @@ const startDownloadListeners = async () => {
|
||||
const registrations = await Promise.allSettled([
|
||||
listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
// A sidecar can flush one last progress chunk after a pause, failure,
|
||||
// or completion event. Do not let that stale chunk repopulate the live
|
||||
// progress map or overwrite a later lifecycle's first frame.
|
||||
if (current && ['completed', 'failed', 'paused'].includes(current.status)) {
|
||||
return;
|
||||
}
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
if (current) {
|
||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||
const updates: Partial<DownloadItem> = {};
|
||||
@@ -81,7 +86,7 @@ const startDownloadListeners = async () => {
|
||||
|
||||
// Prevent race condition: don't transition backwards from terminal state
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
(status !== 'completed' && status !== 'failed')) {
|
||||
status !== current.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -248,8 +248,15 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().removeDownload('late');
|
||||
const remove = useDownloadStore.getState().removeDownload('late');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'cancel_enqueue_generation',
|
||||
expect.objectContaining({ id: 'late' })
|
||||
);
|
||||
});
|
||||
resolveEnqueue({ id: 'late', filename: 'late.bin' });
|
||||
await remove;
|
||||
|
||||
await expect(start).resolves.toEqual([]);
|
||||
expect(useDownloadStore.getState().downloads).toEqual([]);
|
||||
@@ -688,6 +695,134 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
|
||||
});
|
||||
|
||||
it('does not reassign an item that completes while queue assignment is awaiting cancellation', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'race-ready', status: 'ready', queueId: 'old' },
|
||||
{ id: 'race-done', status: 'completed', queueId: 'old' }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
let resolveCancellation!: () => void;
|
||||
const cancellation = new Promise<void>(resolve => {
|
||||
resolveCancellation = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'cancel_enqueue_generation') return cancellation as never;
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
const assignment = useDownloadStore.getState().assignToQueue(['race-ready', 'race-done'], 'new');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'cancel_enqueue_generation',
|
||||
expect.objectContaining({ id: 'race-ready' })
|
||||
);
|
||||
});
|
||||
useDownloadStore.getState().updateDownload('race-ready', { status: 'completed' });
|
||||
resolveCancellation();
|
||||
await assignment;
|
||||
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'race-ready')).toMatchObject({
|
||||
status: 'completed',
|
||||
queueId: 'old'
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels the rest of a queue start when pause is requested during dispatch', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'queue-first', url: 'http://test/first', fileName: 'first', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 0 },
|
||||
{ id: 'queue-second', url: 'http://test/second', fileName: 'second', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 1 }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
|
||||
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
||||
resolveEnqueue = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'enqueue_download') return enqueue as never;
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
const start = useDownloadStore.getState().startQueue('race-queue');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({ item: expect.objectContaining({ id: 'queue-first' }) })
|
||||
);
|
||||
});
|
||||
const pause = useDownloadStore.getState().pauseQueue('race-queue');
|
||||
resolveEnqueue({ id: 'queue-first', filename: 'first' });
|
||||
|
||||
await expect(pause).resolves.toBe(1);
|
||||
await expect(start).resolves.toEqual([]);
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command, args]) =>
|
||||
command === 'enqueue_download' && (args as any)?.item?.id === 'queue-second'
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses one atomic backend move and keeps queue positions unique around active transfers', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'active', status: 'downloading', queueId: 'move-queue', queuePosition: 0 },
|
||||
{ id: 'one', status: 'queued', queueId: 'move-queue', queuePosition: 1 },
|
||||
{ id: 'two', status: 'queued', queueId: 'move-queue', queuePosition: 2 },
|
||||
{ id: 'three', status: 'queued', queueId: 'move-queue', queuePosition: 3 }
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['three']),
|
||||
pendingOrder: ['one', 'two', 'three']
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'move_many_in_queue') return ['one', 'three', 'two'];
|
||||
if (command === 'get_pending_order') return ['one', 'three', 'two'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().moveInQueue('three', 'up');
|
||||
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
|
||||
ids: ['three'],
|
||||
queueId: 'move-queue',
|
||||
direction: 'up'
|
||||
});
|
||||
expect(vi.mocked(ipc.invokeCommand)).not.toHaveBeenCalledWith('move_in_queue', expect.anything());
|
||||
const positions = useDownloadStore.getState().downloads
|
||||
.filter(item => item.queueId === 'move-queue')
|
||||
.map(item => item.queuePosition);
|
||||
expect(new Set(positions).size).toBe(4);
|
||||
});
|
||||
|
||||
it('detaches a registered queued item through the backend before reassigning it', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'registered-queued',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: 'old',
|
||||
queuePosition: 0
|
||||
}],
|
||||
backendRegisteredIds: new Set(['registered-queued']),
|
||||
pendingOrder: ['registered-queued']
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().assignToQueue(['registered-queued'], 'new');
|
||||
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||
'detach_download_for_reconfigure',
|
||||
{ id: 'registered-queued' }
|
||||
);
|
||||
expect(useDownloadStore.getState().pendingOrder).not.toContain('registered-queued');
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('staged');
|
||||
});
|
||||
|
||||
it('disables scheduler when its last selected queue is deleted', async () => {
|
||||
const originalSettings = useSettingsStore.getState();
|
||||
const setScheduler = vi.fn();
|
||||
|
||||
+224
-119
@@ -7,7 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -17,6 +17,56 @@ export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
const backendDispatchPromises = new Map<string, Promise<boolean>>();
|
||||
const downloadLifecycleGenerations = new Map<string, bigint>();
|
||||
const queueReorderPromises = new Map<string, Promise<void>>();
|
||||
const queueStartPromises = new Map<string, Promise<string[]>>();
|
||||
const queueControlGenerations = new Map<string, number>();
|
||||
|
||||
const currentQueueControlGeneration = (queueId: string): number =>
|
||||
queueControlGenerations.get(queueId) ?? 0;
|
||||
|
||||
const advanceQueueControlGeneration = (queueId: string): number => {
|
||||
const nextGeneration = currentQueueControlGeneration(queueId) + 1;
|
||||
queueControlGenerations.set(queueId, nextGeneration);
|
||||
return nextGeneration;
|
||||
};
|
||||
|
||||
const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean =>
|
||||
currentQueueControlGeneration(queueId) === generation;
|
||||
|
||||
const queuePositionComparator = (left: DownloadItem, right: DownloadItem): number =>
|
||||
(left.queuePosition ?? Number.MAX_SAFE_INTEGER) - (right.queuePosition ?? Number.MAX_SAFE_INTEGER) ||
|
||||
left.id.localeCompare(right.id);
|
||||
|
||||
const queueItemsForReordering = (downloads: DownloadItem[], queueId: string): DownloadItem[] =>
|
||||
downloads
|
||||
.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
download.status !== 'completed' &&
|
||||
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||
)
|
||||
.sort(queuePositionComparator);
|
||||
|
||||
const activeQueueItems = (downloads: DownloadItem[], queueId: string): DownloadItem[] =>
|
||||
downloads
|
||||
.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
download.status !== 'completed' &&
|
||||
isActiveDownloadStatus(download.status) &&
|
||||
download.status !== 'queued'
|
||||
)
|
||||
.sort(queuePositionComparator);
|
||||
|
||||
const applyQueueOrder = (
|
||||
downloads: DownloadItem[],
|
||||
queueId: string,
|
||||
pendingItems: DownloadItem[]
|
||||
): DownloadItem[] => {
|
||||
const orderedItems = [...activeQueueItems(downloads, queueId), ...pendingItems];
|
||||
const positions = new Map(orderedItems.map((download, position) => [download.id, position]));
|
||||
return downloads.map(download => positions.has(download.id)
|
||||
? { ...download, queuePosition: positions.get(download.id) }
|
||||
: download);
|
||||
};
|
||||
|
||||
const advanceDownloadLifecycle = (id: string): bigint => {
|
||||
const nextGeneration = (downloadLifecycleGenerations.get(id) ?? 0n) + 1n;
|
||||
@@ -290,7 +340,7 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
|
||||
|
||||
const syncSystemIntegrations = () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
||||
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -395,73 +445,87 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
pendingOrder: [],
|
||||
setPendingOrder: (order) => set({ pendingOrder: order }),
|
||||
moveInQueue: async (idOrIds, direction) => {
|
||||
moveInQueue: (idOrIds, direction) => {
|
||||
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||
if (ids.length === 0) return;
|
||||
|
||||
// Assume all items belong to the same queue as the first item
|
||||
const firstItem = get().downloads.find(d => d.id === ids[0]);
|
||||
if (!firstItem) return;
|
||||
if (ids.length === 0) return Promise.resolve();
|
||||
|
||||
// Queue moves must be serialized per queue. Otherwise two optimistic
|
||||
// moves can calculate from the same order and the last RPC silently wins.
|
||||
const firstItem = get().downloads.find(download => ids.includes(download.id));
|
||||
if (!firstItem) return Promise.resolve();
|
||||
const queueId = firstItem.queueId || MAIN_QUEUE_ID;
|
||||
|
||||
const queueItems = get().downloads
|
||||
.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
download.status !== 'completed' &&
|
||||
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||
)
|
||||
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
|
||||
|
||||
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
const unselectedItems = queueItems.filter(item => !ids.includes(item.id));
|
||||
const selectedIndices = selectedItems.map(item => queueItems.findIndex(d => d.id === item.id));
|
||||
|
||||
let insertIndex = 0;
|
||||
if (direction === 'up') {
|
||||
const firstSelectedIndex = Math.min(...selectedIndices);
|
||||
insertIndex = Math.max(0, firstSelectedIndex - 1);
|
||||
} else {
|
||||
const lastSelectedIndex = Math.max(...selectedIndices);
|
||||
insertIndex = Math.min(unselectedItems.length, lastSelectedIndex - selectedItems.length + 2);
|
||||
}
|
||||
|
||||
const reordered = [
|
||||
...unselectedItems.slice(0, insertIndex),
|
||||
...selectedItems,
|
||||
...unselectedItems.slice(insertIndex)
|
||||
];
|
||||
|
||||
const positions = new Map(reordered.map((download, position) => [download.id, position]));
|
||||
const previousDownloads = get().downloads;
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(download => positions.has(download.id)
|
||||
? { ...download, queuePosition: positions.get(download.id) }
|
||||
: download)
|
||||
}));
|
||||
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
|
||||
const operation = previousOperation.catch(() => undefined).then(async () => {
|
||||
const allDownloads = get().downloads;
|
||||
const queueItems = queueItemsForReordering(allDownloads, queueId);
|
||||
|
||||
const registeredIdsToMove = selectedItems
|
||||
.filter(item => get().backendRegisteredIds.has(item.id))
|
||||
.map(item => item.id);
|
||||
|
||||
if (registeredIdsToMove.length === 0) return;
|
||||
|
||||
// For backend sync, we must call move_in_queue in the correct order to maintain the block
|
||||
const idsToMove = direction === 'up' ? registeredIdsToMove : [...registeredIdsToMove].reverse();
|
||||
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
try {
|
||||
let order: string[] = [];
|
||||
for (const id of idsToMove) {
|
||||
order = (await invoke('move_in_queue', { id, queueId, direction })) as string[];
|
||||
const previousPositions = new Map([
|
||||
...activeQueueItems(allDownloads, queueId),
|
||||
...queueItems
|
||||
].map(item => [item.id, item.queuePosition]));
|
||||
const unselectedItems = queueItems.filter(item => !ids.includes(item.id));
|
||||
const selectedIndices = selectedItems.map(item => queueItems.findIndex(d => d.id === item.id));
|
||||
|
||||
let insertIndex = 0;
|
||||
if (direction === 'up') {
|
||||
const firstSelectedIndex = Math.min(...selectedIndices);
|
||||
insertIndex = Math.max(0, firstSelectedIndex - 1);
|
||||
} else {
|
||||
const lastSelectedIndex = Math.max(...selectedIndices);
|
||||
insertIndex = Math.min(unselectedItems.length, lastSelectedIndex - selectedItems.length + 2);
|
||||
}
|
||||
if (order.length > 0) {
|
||||
set({ pendingOrder: order });
|
||||
|
||||
const reordered = [
|
||||
...unselectedItems.slice(0, insertIndex),
|
||||
...selectedItems,
|
||||
...unselectedItems.slice(insertIndex)
|
||||
];
|
||||
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
|
||||
|
||||
const registeredIdsToMove = selectedItems
|
||||
.filter(item => get().backendRegisteredIds.has(item.id))
|
||||
.map(item => item.id);
|
||||
if (registeredIdsToMove.length === 0) return;
|
||||
|
||||
try {
|
||||
const order = await invoke('move_many_in_queue', {
|
||||
ids: registeredIdsToMove,
|
||||
queueId,
|
||||
direction
|
||||
}) as string[];
|
||||
if (Array.isArray(order)) {
|
||||
const globalOrder = await invoke('get_pending_order', { queueId: null })
|
||||
.catch(() => null) as string[] | null;
|
||||
set(state => ({
|
||||
pendingOrder: Array.isArray(globalOrder)
|
||||
? globalOrder
|
||||
: [
|
||||
...state.pendingOrder.filter(id => !order.includes(id)),
|
||||
...order
|
||||
]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to move in queue backend:", error);
|
||||
// The backend operation is atomic. Restore only queue positions so a
|
||||
// progress/state event received while the RPC was in flight survives.
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(download => previousPositions.has(download.id)
|
||||
? { ...download, queuePosition: previousPositions.get(download.id) }
|
||||
: download)
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to move in queue backend:", e);
|
||||
set({ downloads: previousDownloads });
|
||||
}
|
||||
});
|
||||
const trackedOperation = operation.finally(() => {
|
||||
if (queueReorderPromises.get(queueId) === trackedOperation) {
|
||||
queueReorderPromises.delete(queueId);
|
||||
}
|
||||
});
|
||||
queueReorderPromises.set(queueId, trackedOperation);
|
||||
return trackedOperation;
|
||||
},
|
||||
removeFromQueue: async (id) => {
|
||||
try {
|
||||
@@ -643,8 +707,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (item.status === 'queued') {
|
||||
if (isRegistered) {
|
||||
await invoke('remove_from_queue', { id });
|
||||
await invoke('detach_download_for_reconfigure', { id });
|
||||
state.unregisterBackendIds([id]);
|
||||
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
if (isRegistered || wasDispatching) {
|
||||
@@ -692,7 +757,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
|
||||
await invalidateDispatch(id);
|
||||
const { pendingDispatch } = await invalidateDispatch(id);
|
||||
if (pendingDispatch) {
|
||||
await pendingDispatch;
|
||||
}
|
||||
const item = get().downloads.find(d => d.id === id);
|
||||
|
||||
if (item) {
|
||||
@@ -820,56 +888,89 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
const runnable = get().downloads
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
|
||||
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
|
||||
startQueue: (queueId) => {
|
||||
const requestedGeneration = currentQueueControlGeneration(queueId);
|
||||
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
||||
const operation = previousOperation.catch(() => []).then(async () => {
|
||||
const runnable = get().downloads
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
|
||||
.sort(queuePositionComparator);
|
||||
|
||||
if (runnable.length === 0) return [];
|
||||
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
|
||||
|
||||
const acceptedIds: string[] = [];
|
||||
for (const item of runnable) {
|
||||
const backendRegistered = get().backendRegisteredIds.has(item.id);
|
||||
const backendPending = get().pendingOrder.includes(item.id);
|
||||
const acceptedIds: string[] = [];
|
||||
for (const item of runnable) {
|
||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
|
||||
|
||||
if (item.status === 'queued' && backendRegistered && !backendPending) {
|
||||
if (await get().resumeDownload(item.id)) {
|
||||
const currentItem = get().downloads.find(download => download.id === item.id);
|
||||
if (!currentItem || currentItem.status === 'completed') continue;
|
||||
const backendRegistered = get().backendRegisteredIds.has(item.id);
|
||||
const backendPending = get().pendingOrder.includes(item.id);
|
||||
|
||||
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
|
||||
if (await get().resumeDownload(item.id)) {
|
||||
acceptedIds.push(item.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
currentItem.status === 'ready' ||
|
||||
currentItem.status === 'staged' ||
|
||||
currentItem.status === 'failed' ||
|
||||
!currentItem.hasBeenDispatched ||
|
||||
!backendRegistered
|
||||
) {
|
||||
if (await dispatchItem(item.id)) {
|
||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
|
||||
const afterDispatch = get().downloads.find(download => download.id === item.id);
|
||||
if (
|
||||
backendDispatchPromises.has(item.id) ||
|
||||
get().backendRegisteredIds.has(item.id) ||
|
||||
(afterDispatch && canPauseDownload(afterDispatch.status))
|
||||
) {
|
||||
await get().pauseDownload(item.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const current = get().downloads.find(download => download.id === item.id);
|
||||
get().updateDownload(item.id, {
|
||||
hasBeenDispatched: true,
|
||||
...(current?.status === item.status ? { status: 'queued' as const } : {})
|
||||
});
|
||||
acceptedIds.push(item.id);
|
||||
}
|
||||
} else if (currentItem.status === 'paused' || currentItem.status === 'queued') {
|
||||
// If it's queued but already dispatched, it might be waiting.
|
||||
// If it's paused, we resume it.
|
||||
if (currentItem.status === 'paused') {
|
||||
if (!await get().resumeDownload(item.id)) continue;
|
||||
}
|
||||
acceptedIds.push(item.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
item.status === 'ready' ||
|
||||
item.status === 'staged' ||
|
||||
item.status === 'failed' ||
|
||||
!item.hasBeenDispatched ||
|
||||
!backendRegistered
|
||||
) {
|
||||
if (await dispatchItem(item.id)) {
|
||||
const current = get().downloads.find(download => download.id === item.id);
|
||||
get().updateDownload(item.id, {
|
||||
hasBeenDispatched: true,
|
||||
...(current?.status === item.status ? { status: 'queued' as const } : {})
|
||||
});
|
||||
acceptedIds.push(item.id);
|
||||
}
|
||||
} else if (item.status === 'paused' || item.status === 'queued') {
|
||||
// If it's queued but already dispatched, it might be waiting.
|
||||
// If it's paused, we resume it.
|
||||
if (item.status === 'paused') {
|
||||
if (!await get().resumeDownload(item.id)) continue;
|
||||
}
|
||||
acceptedIds.push(item.id);
|
||||
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
|
||||
return acceptedIds;
|
||||
});
|
||||
const trackedOperation = operation.finally(() => {
|
||||
if (queueStartPromises.get(queueId) === trackedOperation) {
|
||||
queueStartPromises.delete(queueId);
|
||||
}
|
||||
}
|
||||
|
||||
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
|
||||
return acceptedIds;
|
||||
});
|
||||
queueStartPromises.set(queueId, trackedOperation);
|
||||
return trackedOperation;
|
||||
},
|
||||
pauseQueue: async (queueId) => {
|
||||
// Invalidate queued starts before taking the snapshot. This prevents a
|
||||
// start loop that is waiting on metadata/IPC from dispatching later rows
|
||||
// after the user has already requested Pause Queue.
|
||||
advanceQueueControlGeneration(queueId);
|
||||
const activeIds = get().downloads
|
||||
.filter(item => item.queueId === queueId && canPauseDownload(item.status))
|
||||
.filter(item =>
|
||||
item.queueId === queueId &&
|
||||
(canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
|
||||
)
|
||||
.map(item => item.id);
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
@@ -899,8 +1000,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return results.reduce((total, ids) => total + ids.length, 0);
|
||||
},
|
||||
pauseAll: async () => {
|
||||
const queueIds = new Set(
|
||||
get().downloads.map(item => item.queueId || MAIN_QUEUE_ID)
|
||||
);
|
||||
for (const queueId of queueIds) {
|
||||
advanceQueueControlGeneration(queueId);
|
||||
}
|
||||
const activeIds = get().downloads
|
||||
.filter(item => canPauseDownload(item.status))
|
||||
.filter(item => canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
|
||||
.map(item => item.id);
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
@@ -917,35 +1024,33 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
selected
|
||||
.filter(item => item.status !== 'completed')
|
||||
.map(item => invalidateAndWaitForDispatch(item.id))
|
||||
);
|
||||
const movableSelected = selected.filter(item => item.status !== 'completed');
|
||||
const movableIds = new Set(movableSelected.map(item => item.id));
|
||||
await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id)));
|
||||
|
||||
for (const item of get().downloads.filter(item => selectedIds.has(item.id))) {
|
||||
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
|
||||
if (!get().backendRegisteredIds.has(item.id)) continue;
|
||||
if (item.status === 'queued') {
|
||||
await invoke('remove_from_queue', { id: item.id });
|
||||
} else if (item.status === 'paused') {
|
||||
await invoke('detach_download_for_reconfigure', { id: item.id });
|
||||
}
|
||||
// The UI can still say queued while a dispatch has already reached
|
||||
// Aria2/media. Detach through the backend lifecycle owner for every
|
||||
// registered item; remove_from_queue only handles the pending list.
|
||||
await invoke('detach_download_for_reconfigure', { id: item.id });
|
||||
get().unregisterBackendIds([item.id]);
|
||||
set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) }));
|
||||
}
|
||||
|
||||
const queueItems = get().downloads.filter(item =>
|
||||
!selectedIds.has(item.id) &&
|
||||
!movableIds.has(item.id) &&
|
||||
(item.queueId || MAIN_QUEUE_ID) === queueId
|
||||
);
|
||||
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
|
||||
const nextPosition = maxPos + 1;
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
selectedIds.has(item.id) && item.status !== 'completed'
|
||||
movableIds.has(item.id) && item.status !== 'completed'
|
||||
? {
|
||||
...item,
|
||||
queueId,
|
||||
queuePosition: nextPosition + selected.findIndex(selectedItem => selectedItem.id === item.id),
|
||||
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
|
||||
status: 'staged' as const,
|
||||
hasBeenDispatched: false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import {
|
||||
parseDownloadEta,
|
||||
parseDownloadSize,
|
||||
sortDownloads,
|
||||
type DownloadSortConfig
|
||||
} from './downloadTableSorting';
|
||||
import { redactDownloadForPersistence } from './downloads';
|
||||
|
||||
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
|
||||
id,
|
||||
url: `https://example.test/${id}`,
|
||||
fileName: id,
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '2026-07-14T00:00:00.000Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const sortedIds = (downloads: DownloadItem[], config: DownloadSortConfig): string[] =>
|
||||
sortDownloads(downloads, config).map(download => download.id);
|
||||
|
||||
describe('download table sorting', () => {
|
||||
it('compares human-readable sizes by bytes instead of their leading number', () => {
|
||||
expect(parseDownloadSize('1 MB')).toBe(1024 ** 2);
|
||||
expect(sortedIds([
|
||||
item('one-mb', { size: '1 MB' }),
|
||||
item('900-kb', { size: '900 KB' }),
|
||||
item('two-mb', { size: '2 MB' })
|
||||
], { column: 'Size', direction: 'asc' })).toEqual(['900-kb', 'one-mb', 'two-mb']);
|
||||
});
|
||||
|
||||
it('supports clock and unit ETA values and keeps unknown values last', () => {
|
||||
expect(parseDownloadEta('01:02:03')).toBe(3723);
|
||||
expect(parseDownloadEta('2m 5s')).toBe(125);
|
||||
expect(sortedIds([
|
||||
item('unknown', { eta: '-' }),
|
||||
item('long', { eta: '2m' }),
|
||||
item('short', { eta: '10s' })
|
||||
], { column: 'ETA', direction: 'asc' })).toEqual(['short', 'long', 'unknown']);
|
||||
});
|
||||
|
||||
it('sorts descending on the second click without reverting to an unsorted list', () => {
|
||||
const downloads = [item('b', { fileName: 'Beta' }), item('a', { fileName: 'Alpha' })];
|
||||
expect(sortedIds(downloads, { column: 'File Name', direction: 'asc' })).toEqual(['a', 'b']);
|
||||
expect(sortedIds(downloads, { column: 'File Name', direction: 'desc' })).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('does not persist volatile progress fields', () => {
|
||||
const persisted = redactDownloadForPersistence(item('volatile', {
|
||||
fraction: 0.75,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s'
|
||||
}));
|
||||
expect(persisted.fraction).toBeUndefined();
|
||||
expect(persisted.speed).toBeUndefined();
|
||||
expect(persisted.eta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
|
||||
export type DownloadSortColumn = 'File Name' | 'Size' | 'Status' | 'Speed' | 'ETA' | 'Date Added';
|
||||
export type DownloadSortDirection = 'asc' | 'desc';
|
||||
|
||||
export type DownloadSortConfig = {
|
||||
column: DownloadSortColumn;
|
||||
direction: DownloadSortDirection;
|
||||
};
|
||||
|
||||
const SIZE_UNITS: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
KIB: 1024,
|
||||
MB: 1024 ** 2,
|
||||
MIB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
GIB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
TIB: 1024 ** 4,
|
||||
};
|
||||
|
||||
const valueOrNull = (value?: string): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
|
||||
};
|
||||
|
||||
const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
|
||||
const normalized = valueOrNull(value);
|
||||
if (!normalized) return null;
|
||||
const match = normalized.match(/^([\d.,]+)\s*([KMGT]?I?B)(?:\/s)?$/i);
|
||||
if (!match) {
|
||||
const number = Number(normalized.replace(/,/g, ''));
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
const amount = Number(match[1].replace(/,/g, ''));
|
||||
const multiplier = units[match[2].toUpperCase()];
|
||||
return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
|
||||
};
|
||||
|
||||
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
|
||||
|
||||
export const parseDownloadSpeed = (value?: string): number | null =>
|
||||
parseUnitValue(value, SIZE_UNITS);
|
||||
|
||||
export const parseDownloadEta = (value?: string): number | null => {
|
||||
const normalized = valueOrNull(value);
|
||||
if (!normalized) return null;
|
||||
|
||||
const clockParts = normalized.split(':').map(part => Number(part));
|
||||
if (clockParts.length >= 2 && clockParts.every(Number.isFinite)) {
|
||||
return clockParts.reduce((total, part, index) => total + part * 60 ** (clockParts.length - index - 1), 0);
|
||||
}
|
||||
|
||||
let seconds = 0;
|
||||
let matched = false;
|
||||
for (const [pattern, multiplier] of [[/(\d+(?:\.\d+)?)h/i, 3600], [/(\d+(?:\.\d+)?)m/i, 60], [/(\d+(?:\.\d+)?)s/i, 1]] as const) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match) {
|
||||
seconds += Number(match[1]) * multiplier;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
return matched && Number.isFinite(seconds) ? seconds : null;
|
||||
};
|
||||
|
||||
const compareValues = (left: string | number | null, right: string | number | null): number => {
|
||||
if (left === null && right === null) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
};
|
||||
|
||||
const parseDownloadDate = (value?: string): number | null => {
|
||||
if (!value?.trim()) return null;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
};
|
||||
|
||||
export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortConfig): DownloadItem[] => {
|
||||
const sorted = [...downloads].sort((left, right) => {
|
||||
let comparison: number;
|
||||
switch (config.column) {
|
||||
case 'File Name':
|
||||
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
|
||||
break;
|
||||
case 'Size':
|
||||
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
|
||||
break;
|
||||
case 'Status':
|
||||
comparison = compareValues(left.status, right.status);
|
||||
break;
|
||||
case 'Speed':
|
||||
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
|
||||
break;
|
||||
case 'ETA':
|
||||
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
|
||||
break;
|
||||
case 'Date Added':
|
||||
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
|
||||
break;
|
||||
}
|
||||
|
||||
if (comparison === 0) comparison = left.id.localeCompare(right.id);
|
||||
return config.direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return sorted;
|
||||
};
|
||||
@@ -36,6 +36,10 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||
ACTIVE_DOWNLOAD_STATUSES.has(status);
|
||||
|
||||
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
|
||||
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -128,6 +132,7 @@ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
||||
*/
|
||||
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
|
||||
const copy: DownloadItem = { ...item };
|
||||
delete copy.fraction;
|
||||
delete copy.speed;
|
||||
delete copy.eta;
|
||||
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
||||
|
||||
Reference in New Issue
Block a user