/
nkolentcev
/
devscripts
Обзор
Документация
Войти
/
nkolentcev
/
devscripts
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
onec/tui/src/app.rs
522 строки
17 KB
Nikolay Kolentcev
orc: распознавание полных дистрибутивов + модалка прогресса с потоком
01 май 2026, 17:43
01 май 2026, 17:43
66d9872
Код
Авторство
О чём код?
use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use crate::download::path_for_download; use crate::parser::{is_platform_row, platform_line, ProjectRow, VersionRow}; use crate::releases_client::ReleasesClient; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Kind { Platform, Configuration, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub enum LineFilter { #[default] All, V83, V85, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Focus { Left, Center, Right, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Phase { PickKind, Browse, } #[derive(Clone, Debug)] pub struct QueueItem { pub kind: Kind, pub line_label: String, pub project_id: String, pub project_name: String, pub version_name: String, pub version_path: String, pub marked: bool, } #[allow(dead_code)] pub enum DownloadEvent { FileBegin { idx: usize, name: String, }, FileProgress { idx: usize, bytes: u64, total: Option<u64>, }, FileDone { idx: usize, }, FileError { idx: usize, err: String, }, } pub struct DownloadOutcome { pub ok: usize, pub errors: Vec<String>, } pub struct DownloadJob { pub rx: mpsc::Receiver<DownloadEvent>, pub cancel: Arc<AtomicBool>, pub handle: Option<thread::JoinHandle<DownloadOutcome>>, pub current_idx: usize, pub current_name: String, pub current_bytes: u64, pub current_total: Option<u64>, pub files_done: usize, pub errors: Vec<String>, pub started: Instant, } pub struct App { pub phase: Phase, pub kind: Option<Kind>, pub line_filter: LineFilter, pub focus: Focus, pub projects: Vec<ProjectRow>, pub center_indices: Vec<usize>, pub center_cursor: usize, pub selected_project_idx: Option<usize>, pub versions: Vec<VersionRow>, pub version_cursor: usize, pub queue: Vec<QueueItem>, pub left_cursor: usize, pub status: String, pub last_key: Option<char>, pub last_key_at: Option<Instant>, pub client: Arc<Mutex<ReleasesClient>>, pub download: Option<DownloadJob>, } impl App { pub fn new(projects: Vec<ProjectRow>, client: ReleasesClient) -> Self { let mut s = Self { phase: Phase::PickKind, kind: None, line_filter: LineFilter::All, focus: Focus::Center, projects, center_indices: Vec::new(), center_cursor: 0, selected_project_idx: None, versions: Vec::new(), version_cursor: 0, queue: Vec::new(), left_cursor: 0, status: String::new(), last_key: None, last_key_at: None, client: Arc::new(Mutex::new(client)), download: None, }; s.set_status( "hjkl — строка под фокусом · [ ] — всегда проекты · Tab/⇧Tab фокус · Space · dd/yy · q", ); s } pub fn set_status(&mut self, msg: impl Into<String>) { self.status = msg.into(); } fn refilter_center(&mut self) { let Some(kind) = self.kind else { self.center_indices.clear(); return; }; self.center_indices = self .projects .iter() .enumerate() .filter(|(_, p)| match kind { Kind::Platform => is_platform_row(p), Kind::Configuration => !is_platform_row(p), }) .map(|(i, _)| i) .collect(); if self.center_cursor >= self.center_indices.len() { self.center_cursor = self.center_indices.len().saturating_sub(1); } } pub fn enter_kind(&mut self, kind: Kind) { self.kind = Some(kind); self.phase = Phase::Browse; self.focus = Focus::Center; self.center_cursor = 0; self.selected_project_idx = None; self.versions.clear(); self.refilter_center(); self.set_status(match kind { Kind::Platform => "Платформа: 1/2/3 линия 8.x · l — версии справа", Kind::Configuration => "Конфигурация: l — список версий справа", }); } pub fn back_to_root(&mut self) { self.phase = Phase::PickKind; self.kind = None; self.focus = Focus::Center; self.center_cursor = 0; self.selected_project_idx = None; self.versions.clear(); self.set_status("Выберите тип: Платформа или Конфигурация"); } pub fn refresh_versions(&mut self) { if self.selected_project_idx.is_some() { self.select_project(); } } pub fn current_project_row(&self) -> Option<&ProjectRow> { let idx = self.center_indices.get(self.center_cursor).copied()?; self.projects.get(idx) } pub fn apply_line_filter(&self, versions: &[VersionRow]) -> Vec<VersionRow> { match self.kind { Some(Kind::Platform) => match self.line_filter { LineFilter::All => versions.to_vec(), LineFilter::V83 => versions .iter() .filter(|v| v.name.trim().starts_with("8.3")) .cloned() .collect(), LineFilter::V85 => versions .iter() .filter(|v| v.name.trim().starts_with("8.5")) .cloned() .collect(), }, _ => versions.to_vec(), } } pub fn select_project(&mut self) { if self.center_indices.is_empty() { return; } let Some(row) = self.current_project_row().cloned() else { return; }; self.selected_project_idx = Some(self.center_indices[self.center_cursor]); let result = self .client .lock() .expect("client mutex poisoned") .fetch_project_versions(&row.id); match result { Ok(v) => { self.versions = self.apply_line_filter(&v); self.version_cursor = 0; if self.versions.is_empty() { self.set_status("Нет версий для выбранного фильтра линии — смените 1/2/3"); } else { self.set_status(format!("Версии: {} шт.", self.versions.len())); } } Err(e) => { self.set_status(format!("Ошибка загрузки версий: {e}")); self.versions.clear(); } } } pub fn toggle_queue_current_version(&mut self) { let Some(kind) = self.kind else { return }; let Some(row) = self.current_project_row().cloned() else { return; }; let Some(ver) = self.versions.get(self.version_cursor).cloned() else { return; }; let line_label = platform_line(&ver.name).to_string(); let key = (row.id.clone(), ver.name.clone()); if let Some(i) = self.queue.iter().position(|q| { q.project_id == key.0 && q.version_name == key.1 }) { self.queue.remove(i); self.set_status("Убрано из очереди"); return; } self.queue.push(QueueItem { kind, line_label, project_id: row.id.clone(), project_name: row.name.clone(), version_name: ver.name.clone(), version_path: ver.url.clone(), marked: true, }); self.set_status("Добавлено в очередь загрузки"); } pub fn toggle_queue_left(&mut self) { if let Some(it) = self.queue.get_mut(self.left_cursor) { it.marked = !it.marked; } } pub fn marked_for_download(&self) -> Vec<&QueueItem> { self.queue.iter().filter(|q| q.marked).collect() } pub fn feed_double_key(&mut self, c: char) -> bool { const WIN: Duration = Duration::from_millis(450); let now = Instant::now(); let trigger = if c == 'd' || c == 'y' { match (self.last_key, self.last_key_at) { (Some(prev), Some(t)) if prev == c && now.duration_since(t) < WIN => true, _ => false, } } else { false }; if trigger { self.last_key = None; self.last_key_at = None; return true; } if c == 'd' || c == 'y' { self.last_key = Some(c); self.last_key_at = Some(now); } else { self.last_key = None; self.last_key_at = None; } false } /// Стартует фоновую загрузку (см. `download_thread`). UI остаётся отзывчивым: /// модалка читает события из канала через `poll_downloads`, `Esc` → cancel. pub fn start_downloads(&mut self, dest: PathBuf) { if self.download.is_some() { self.set_status("Загрузка уже идёт"); return; } let items: Vec<QueueItem> = self .marked_for_download() .into_iter() .cloned() .collect(); if items.is_empty() { self.set_status("Нет отмеченных элементов"); return; } let (tx, rx) = mpsc::channel::<DownloadEvent>(); let cancel = Arc::new(AtomicBool::new(false)); let cancel_for_thread = cancel.clone(); let client = self.client.clone(); let handle = thread::spawn(move || download_thread(client, items, dest, tx, cancel_for_thread)); self.download = Some(DownloadJob { rx, cancel, handle: Some(handle), current_idx: 0, current_name: String::new(), current_bytes: 0, current_total: None, files_done: 0, errors: Vec::new(), started: Instant::now(), }); self.set_status("Загрузка запущена"); } /// Дренирует канал прогресса и собирает результат, если поток завершился. /// Возвращает `true`, если состояние изменилось — UI стоит перерисовать. pub fn poll_downloads(&mut self) -> bool { let Some(dj) = self.download.as_mut() else { return false; }; let mut changed = false; loop { match dj.rx.try_recv() { Ok(DownloadEvent::FileBegin { idx, name }) => { dj.current_idx = idx; dj.current_name = name; dj.current_bytes = 0; dj.current_total = None; changed = true; } Ok(DownloadEvent::FileProgress { idx, bytes, total }) => { if idx == dj.current_idx { dj.current_bytes = bytes; if total.is_some() { dj.current_total = total; } changed = true; } } Ok(DownloadEvent::FileDone { .. }) => { dj.files_done += 1; changed = true; } Ok(DownloadEvent::FileError { err, .. }) => { dj.errors.push(err); dj.files_done += 1; changed = true; } Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => break, } } let finished = dj .handle .as_ref() .map(|h| h.is_finished()) .unwrap_or(true); if finished { let outcome = dj.handle.take().and_then(|h| h.join().ok()); let was_cancelled = dj.cancel.load(Ordering::Relaxed); self.download = None; let summary = match outcome { Some(o) if o.errors.is_empty() => { if was_cancelled { format!("Отменено. Скачано до отмены: {}", o.ok) } else { format!("Скачано файлов: {}", o.ok) } } Some(o) => format!( "Скачано: {}; ошибок: {} ({})", o.ok, o.errors.len(), o.errors.first().map(String::as_str).unwrap_or("") ), None => "Поток загрузки завершился аварийно".to_string(), }; self.set_status(summary); changed = true; } changed } pub fn cancel_downloads(&mut self) { if let Some(dj) = self.download.as_mut() { dj.cancel.store(true, Ordering::Relaxed); self.set_status("отмена загрузки…"); } } } fn download_thread( client: Arc<Mutex<ReleasesClient>>, items: Vec<QueueItem>, dest: PathBuf, tx: mpsc::Sender<DownloadEvent>, cancel: Arc<AtomicBool>, ) -> DownloadOutcome { let mut ok = 0usize; let mut errors: Vec<String> = Vec::new(); let mut next_idx: usize = 0; 'outer: for item in items { if cancel.load(Ordering::Relaxed) { break; } let mut c = match client.lock() { Ok(g) => g, Err(_) => { errors.push(format!("{}: client mutex poisoned", item.version_name)); continue; } }; let files = match c.release_files(&item.version_path) { Ok(v) => v, Err(e) => { errors.push(format!("{}: release_files: {}", item.version_name, e)); continue; } }; for f in files { if cancel.load(Ordering::Relaxed) { break 'outer; } let links = match c.expand_download_links(&f.url) { Ok(v) => v, Err(e) => { errors.push(format!("{}: expand: {}", item.version_name, e)); continue; } }; for link in links { if cancel.load(Ordering::Relaxed) { break 'outer; } let target = match path_for_download(&link, &dest) { Ok(p) => p, Err(e) => { errors.push(format!("{}: path: {}", item.version_name, e)); continue; } }; let name = target .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| link.clone()); let idx = next_idx; next_idx += 1; let _ = tx.send(DownloadEvent::FileBegin { idx, name: name.clone(), }); let mut last_emit = Instant::now(); let mut last_emit_bytes: u64 = 0; let progress_tx = tx.clone(); let cancel_ref = cancel.clone(); let r = c.download_to_path_with_progress( &link, &target, &cancel_ref, |bytes, total| { let now = Instant::now(); let big_step = bytes.saturating_sub(last_emit_bytes) >= 256 * 1024; let time_step = now.duration_since(last_emit) >= Duration::from_millis(80); if bytes == 0 || big_step || time_step { let _ = progress_tx.send(DownloadEvent::FileProgress { idx, bytes, total, }); last_emit = now; last_emit_bytes = bytes; } }, ); match r { Ok(_) => { ok += 1; let _ = tx.send(DownloadEvent::FileDone { idx }); } Err(e) => { let msg = format!("{}: {}", name, e); errors.push(msg.clone()); let _ = tx.send(DownloadEvent::FileError { idx, err: msg }); } } } } } DownloadOutcome { ok, errors } }