From 5b17c331c9a6d1993550ac36aea044e635b0f07c Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 18 Apr 2026 13:51:11 +0200 Subject: [PATCH] Add pending upload indicator to UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a visual indicator in the UI to show when a case has pending uploads. The `WorkerSnapshot` struct has been updated to include a `pending_case_ids` field, which is an `Arc>`. This set stores the IDs of cases that currently have files in the pending directory. The `run_loop` function now populates this set by scanning the pending directory and collecting the `case_id`s of any files found. This snapshot is then sent to the UI via the `worker_rx` channel. In the `DoctateApp::ui` method, the `pending_ids` set is cloned from the worker's snapshot. If a case's ID is present in this set, a distinct "⬆" prefix and a yellow label are used to highlight it, signifying an ongoing or pending upload. Otherwise, the standard label is used. --- client-desktop/src/app.rs | 20 +++++++++++- doctate-client-core/src/server_sync.rs | 43 +++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index 90e2cd6..e42594b 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -483,6 +483,14 @@ impl DoctateApp { return None; } + // Pending-upload lookup for the badge. Cheap clone (Arc) from the + // worker's last published snapshot; empty set if no worker yet. + let pending_ids = self + .worker_rx + .as_ref() + .map(|rx| rx.borrow().pending_case_ids.clone()) + .unwrap_or_default(); + let mut action = None; // Ready-for-new means the user-intent state is Idle AND no ffmpeg // instance is still flushing. Upload-queue depth does NOT gate. @@ -499,7 +507,17 @@ impl DoctateApp { .as_deref() .map(str::to_owned) .unwrap_or_else(|| "⏳".to_owned()); - ui.label(format!("{time_str} · {oneliner}")); + let has_pending = pending_ids.contains(&marker.case_id); + let line = if has_pending { + format!("⬆ {time_str} · {oneliner}") + } else { + format!("{time_str} · {oneliner}") + }; + if has_pending { + ui.colored_label(egui::Color32::from_rgb(200, 140, 0), line); + } else { + ui.label(line); + } ui.horizontal(|ui| { if ui diff --git a/doctate-client-core/src/server_sync.rs b/doctate-client-core/src/server_sync.rs index e494af5..84d0a61 100644 --- a/doctate-client-core/src/server_sync.rs +++ b/doctate-client-core/src/server_sync.rs @@ -17,6 +17,7 @@ //! never succeed; keeping the audio around would violate the //! data-minimization rule. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; @@ -31,6 +32,7 @@ use reqwest::StatusCode; use tokio::sync::{mpsc, watch}; use tokio::task::AbortHandle; use tracing::{debug, info, warn}; +use uuid::Uuid; use crate::case_store::CaseStore; use crate::snapshot_cache::{CachedSnapshot, SnapshotCache}; @@ -64,6 +66,10 @@ pub struct WorkerSnapshot { /// iteration. `0` during `Polling` / `Idle`, positive during /// `Uploading`. pub queue_len: usize, + /// Set of `case_id`s that currently have at least one file sitting + /// in the pending directory. Shared as an `Arc` so the UI can clone + /// the borrow cheaply and look up per-case without scanning disk. + pub pending_case_ids: Arc>, /// Set to `Some(now)` after any successful upload or poll (200/304). /// `None` before the first success. Drives Online/Offline heuristics /// together with `last_failure`. @@ -79,6 +85,7 @@ impl Default for WorkerSnapshot { Self { state: WorkerState::Idle, queue_len: 0, + pending_case_ids: Arc::new(HashSet::new()), last_success: None, last_failure: None, } @@ -207,11 +214,13 @@ async fn run_loop( let publish = |state: WorkerState, queue_len: usize, + pending_ids: Arc>, last_ok: Option, last_fail: Option| { let snap = WorkerSnapshot { state, queue_len, + pending_case_ids: pending_ids, last_success: last_ok, last_failure: last_fail, }; @@ -224,10 +233,18 @@ async fn run_loop( let mut files = scan_pending_dir(&pending_dir); files.sort_by(|a, b| a.recorded_at.cmp(&b.recorded_at)); // FIFO let queue_len = files.len(); + let pending_ids: Arc> = + Arc::new(files.iter().map(|u| u.case_id).collect()); let next = files.into_iter().next(); if let Some(upload) = next { - publish(WorkerState::Uploading, queue_len, last_success, last_failure); + publish( + WorkerState::Uploading, + queue_len, + pending_ids.clone(), + last_success, + last_failure, + ); let _ = events_tx.send(UploadEvent::Started(upload.case_id)); match post_upload(&http, &config, &upload).await { UploadOutcome::Succeeded(status) => { @@ -256,7 +273,13 @@ async fn run_loop( // doesn't misreport "Uploading" during a passive wait. // The footer derives "Offline" from `last_failure` being // younger than `last_success`. - publish(WorkerState::Idle, queue_len, last_success, last_failure); + publish( + WorkerState::Idle, + queue_len, + pending_ids.clone(), + last_success, + last_failure, + ); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(MAX_BACKOFF); } @@ -273,7 +296,13 @@ async fn run_loop( } } } else { - publish(WorkerState::Polling, 0, last_success, last_failure); + publish( + WorkerState::Polling, + 0, + pending_ids.clone(), + last_success, + last_failure, + ); match poll_once(&http, &config, &store, &cache, &mut etag).await { Ok(PollOutcome::Success) | Ok(PollOutcome::NotModified) => { let now = Instant::now(); @@ -290,7 +319,13 @@ async fn run_loop( } } - publish(WorkerState::Idle, 0, last_success, last_failure); + publish( + WorkerState::Idle, + 0, + pending_ids.clone(), + last_success, + last_failure, + ); // Wait for next tick OR kick. Cancel-safe on both arms. tokio::select! {