From 0ea7d10a9cf7aa469cca4029ee086639c67944ec Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 21:55:02 +0200 Subject: [PATCH] Add rsync progress bar to Running Tasks screen - Add --info=progress2 --no-inc-recursive to rsync for overall progress - Parse rsync progress output (percentage, speed, ETA) from log file - Show ProgressBar widget and progress label below buttons - Progress bar auto-hides when job finishes Co-Authored-By: Claude Opus 4.6 --- lib/transfer.sh | 6 +++ tui/gniza.tcss | 10 +++++ tui/screens/running_tasks.py | 77 ++++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/lib/transfer.sh b/lib/transfer.sh index 50e3ec0..7c04518 100644 --- a/lib/transfer.sh +++ b/lib/transfer.sh @@ -39,6 +39,9 @@ rsync_to_remote() { rsync_opts+=(--verbose --stats) fi + # Overall progress for TUI progress bar + rsync_opts+=(--info=progress2 --no-inc-recursive) + rsync_opts+=(-e "$rsync_ssh") # Ensure source ends with / @@ -123,6 +126,9 @@ rsync_local() { rsync_opts+=(--verbose --stats) fi + # Overall progress for TUI progress bar + rsync_opts+=(--info=progress2 --no-inc-recursive) + # Ensure source ends with / [[ "$source_dir" != */ ]] && source_dir="$source_dir/" diff --git a/tui/gniza.tcss b/tui/gniza.tcss index 74e9ad7..7c994af 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -286,6 +286,16 @@ SelectionList { margin: 1 0; } +/* Progress bar */ +#rt-progress { + margin: 0 0; +} + +#rt-progress-label { + height: 1; + color: #00cc00; +} + /* Wizard */ #wizard { width: 60; diff --git a/tui/screens/running_tasks.py b/tui/screens/running_tasks.py index fff41c3..4b98799 100644 --- a/tui/screens/running_tasks.py +++ b/tui/screens/running_tasks.py @@ -1,15 +1,18 @@ +import re from datetime import datetime from pathlib import Path from textual.app import ComposeResult from textual.screen import Screen -from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog +from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog, ProgressBar from textual.containers import Vertical, Horizontal from textual.timer import Timer from tui.jobs import job_manager from tui.widgets import ConfirmDialog +_PROGRESS_RE = re.compile(r"(\d+)%") + class RunningTasksScreen(Screen): @@ -25,6 +28,8 @@ class RunningTasksScreen(Screen): yield Button("Kill Job", variant="error", id="btn-rt-kill") yield Button("Clear Finished", variant="warning", id="btn-rt-clear") yield Button("Back", id="btn-rt-back") + yield Static("", id="rt-progress-label") + yield ProgressBar(id="rt-progress", total=100, show_eta=False) yield RichLog(id="rt-log-viewer", wrap=True, highlight=True) yield Footer() @@ -37,6 +42,9 @@ class RunningTasksScreen(Screen): self._log_timer: Timer | None = None self._viewing_job_id: str | None = None self._log_file_pos: int = 0 + # Hide progress bar initially + self.query_one("#rt-progress", ProgressBar).display = False + self.query_one("#rt-progress-label", Static).display = False def _format_duration(self, job) -> str: end = job.finished_at or datetime.now() @@ -95,24 +103,67 @@ class RunningTasksScreen(Screen): log_viewer.clear() self._viewing_job_id = job_id self._log_file_pos = 0 + # Reset progress bar + progress = self.query_one("#rt-progress", ProgressBar) + label = self.query_one("#rt-progress-label", Static) + progress.update(progress=0) # Load existing content from log file if job._log_file and Path(job._log_file).is_file(): try: - content = Path(job._log_file).read_text() - self._log_file_pos = len(content.encode()) - for line in content.splitlines(): - log_viewer.write(line) + raw = Path(job._log_file).read_bytes() + self._log_file_pos = len(raw) + content = raw.decode(errors="replace") + self._process_log_content(content, log_viewer) except OSError: pass elif job.output: for line in job.output: log_viewer.write(line) + # Show/hide progress bar based on job status + is_running = job.status == "running" + progress.display = is_running + label.display = is_running # Start polling for new content if job is running if self._log_timer: self._log_timer.stop() - if job.status == "running": + if is_running: self._log_timer = self.set_interval(0.3, self._poll_log) + def _process_log_content(self, content: str, log_viewer: RichLog) -> None: + """Process log content, extracting rsync progress and writing log lines.""" + for line in content.split("\n"): + if not line: + continue + # rsync --info=progress2 uses \r to update in place + if "\r" in line: + parts = line.split("\r") + # Extract progress from the last \r segment + last = parts[-1].strip() + if last: + self._update_progress(last) + # Write non-progress parts as log lines + for part in parts: + part = part.strip() + if part and not _PROGRESS_RE.search(part): + log_viewer.write(part) + else: + log_viewer.write(line) + + def _update_progress(self, text: str) -> None: + """Parse rsync progress2 line and update progress bar.""" + m = _PROGRESS_RE.search(text) + if not m: + return + pct = int(m.group(1)) + try: + progress = self.query_one("#rt-progress", ProgressBar) + label = self.query_one("#rt-progress-label", Static) + progress.update(progress=pct) + # Show the raw progress info as label + label.update(f" {text.strip()}") + except Exception: + pass + def _poll_log(self) -> None: if not self._viewing_job_id: return @@ -127,16 +178,18 @@ class RunningTasksScreen(Screen): return if job._log_file and Path(job._log_file).is_file(): try: - with open(job._log_file, "r") as f: + with open(job._log_file, "rb") as f: f.seek(self._log_file_pos) - new_data = f.read() - if new_data: - self._log_file_pos += len(new_data.encode()) - for line in new_data.splitlines(): - log_viewer.write(line) + new_raw = f.read() + if new_raw: + self._log_file_pos += len(new_raw) + new_data = new_raw.decode(errors="replace") + self._process_log_content(new_data, log_viewer) except OSError: pass if job.status != "running": + self.query_one("#rt-progress", ProgressBar).display = False + self.query_one("#rt-progress-label", Static).display = False if self._log_timer: self._log_timer.stop()