Add pending upload indicator to UI

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<HashSet<Uuid>>`. 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.
This commit is contained in:
2026-04-18 13:51:11 +02:00
parent 79e91246c6
commit 5b17c331c9
2 changed files with 58 additions and 5 deletions
+19 -1
View File
@@ -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
+39 -4
View File
@@ -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<HashSet<Uuid>>,
/// 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<HashSet<Uuid>>,
last_ok: Option<Instant>,
last_fail: Option<Instant>| {
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<HashSet<Uuid>> =
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! {