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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 21:55:02 +02:00
parent 666f1bd52a
commit 0ea7d10a9c
3 changed files with 81 additions and 12 deletions

View File

@@ -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/"

View File

@@ -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;

View File

@@ -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()