feat: TUI 命令 stop/pause/continue + 单文件进度条改为文件计数轮询

This commit is contained in:
AskaEth committed 2026-08-22 14:03:40 +08:00
1 parent 7ab32bad24
commit 81857d6343
3 files changed
+145 -9

No files matched your search

+79 -6
View File
@@ -13,6 +13,7 @@ import argparse
import os
import re
import shlex
import signal
import subprocess
import sys
import threading
@@ -67,6 +68,18 @@ def dir_size(path: str) -> int:
return 0
def count_files(path: str) -> int:
"""统计目录内文件数 (单文件进度条数据源)"""
if not os.path.isdir(path):
return 0
try:
proc = subprocess.run(["find", path, "-type", "f"],
capture_output=True, text=True, timeout=60)
return len([l for l in proc.stdout.splitlines() if l.strip()])
except (OSError, subprocess.TimeoutExpired):
return 0
def speed_stats(history) -> tuple[float, float, float]:
"""速度统计: (当前, 峰值, 平均) 字节/秒"""
if not history:
@@ -463,7 +476,10 @@ class TaskRunner:
self.proc: subprocess.Popen | None = None
self.stats = ProgressStats()
self._stop = threading.Event()
self._paused = False
self.stats_frames = 0
self.total_bytes = 0
self.total_files = 0
# -- 命令构建
def build_command(self) -> list[str]:
@@ -582,7 +598,7 @@ class TaskRunner:
self.on_log(f"[yellow]补传轮次 {attempt}/{self.cfg.retry}[/]")
time.sleep(3) # 等守护恢复设备
rc = self._run_once()
if rc == 0:
if rc == 0 or self._stop.is_set():
break
self.lm.write(f"attempt {attempt + 1} failed rc={rc}")
if self.on_error and attempt < self.cfg.retry:
@@ -600,11 +616,14 @@ class TaskRunner:
f"resume={self.cfg.resume} checksum={self.cfg.checksum}")
# 源总大小 (进度分母)
self.total_bytes = dir_size(self.cfg.source.rstrip("/"))
self.lm.write(f"source size: {human_size(self.total_bytes)}")
self.total_files = count_files(self.cfg.source.rstrip("/"))
self.stats.files_total = self.total_files
self.lm.write(f"source size: {human_size(self.total_bytes)}, "
f"files: {self.total_files}")
# stdout 丢弃 (进度帧缓冲不可靠), stderr 单独读错误
self.proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, bufsize=1,
text=True, bufsize=1, start_new_session=True,
)
# 轮询线程: 每秒统计目标目录大小驱动进度
self._stop.clear()
@@ -642,6 +661,8 @@ class TaskRunner:
poll = 0
while not self._stop.is_set():
time.sleep(1)
if self._paused:
continue # 暂停期间不轮询
done = dir_size(dst)
now = time.time()
speed = (done - last) / max(0.1, now - last_t)
@@ -651,6 +672,9 @@ class TaskRunner:
if self.total_bytes:
self.stats.pct = min(100.0, done / self.total_bytes * 100)
last, last_t = done, now
# 每 3 秒更新文件计数 (find 开销控制)
if self.total_files and poll % 3 == 0:
self.stats.files_done = count_files(dst)
self._emit_stats()
poll += 1
# 每 5 秒一条进度快照到日志栏
@@ -687,10 +711,39 @@ class TaskRunner:
else:
self.on_log(f"[red]✘ 校验发现 {diffs} 个差异 (rc={rc})[/]")
def pause(self) -> None:
"""暂停: SIGSTOP 整个进程组"""
if self._paused:
return
proc = self.proc
if proc and proc.poll() is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGSTOP)
except (ProcessLookupError, PermissionError, OSError):
proc.send_signal(signal.SIGSTOP)
self._paused = True
def resume(self) -> None:
"""继续: SIGCONT 整个进程组"""
if not self._paused:
return
proc = self.proc
if proc and proc.poll() is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGCONT)
except (ProcessLookupError, PermissionError, OSError):
proc.send_signal(signal.SIGCONT)
self._paused = False
def stop(self) -> None:
self._stop.set()
if self.proc and self.proc.poll() is None:
self.proc.terminate()
if self._paused:
self.resume()
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
self.proc.terminate()
# ---------------------------------------------------------------- 冲突交互
@@ -861,7 +914,7 @@ class TransferApp(App):
yield RichLog(id="file-log", highlight=True, markup=True, max_lines=200)
yield RichLog(id="error-log", highlight=True, markup=True, max_lines=100)
with Horizontal(id="cmd-bar"):
yield Input(placeholder="传输任务: 源路径 目标路径 [--backend rsync|rclone|tar --bwlimit 30000 ...] | help / quit", id="cmd")
yield Input(placeholder="传输任务: 源 目标 [参数] | stop / pause / continue / quit / help", id="cmd")
yield Footer()
def on_mount(self) -> None:
@@ -890,6 +943,9 @@ class TransferApp(App):
if low in ("q", "quit", "exit"):
self.exit()
return
if low in ("stop", "pause", "continue", "cont", "resume"):
self._handle_transport_cmd(low)
return
if low in ("h", "help", "?"):
self._show_help()
return
@@ -902,6 +958,21 @@ class TransferApp(App):
return
self._parse_and_start(text)
def _handle_transport_cmd(self, cmd: str) -> None:
"""传输控制命令: stop / pause / continue"""
if not self.runner:
self._flash("[yellow]当前无任务[/]")
return
if cmd == "stop":
self.runner.stop()
self._flash("[red]已发送停止信号[/]")
elif cmd == "pause":
self.runner.pause()
self._flash("[yellow]已暂停 (输入 continue 恢复)[/]")
elif cmd in ("continue", "cont", "resume"):
self.runner.resume()
self._flash("[green]已恢复[/]")
def _handle_ask_input(self, text: str) -> None:
choice = text.strip().lower()
ask = self._ask
@@ -980,7 +1051,7 @@ class TransferApp(App):
ask = self._ask
self._ask = None
self.query_one("#cmd", Input).placeholder = (
"传输任务: 源路径 目标路径 [--backend rsync|rclone|tar --bwlimit 30000 ...] | help / quit")
"传输任务: 源 目标 [参数] | stop / pause / continue / quit / help")
try:
skipped = apply_policy(ask["updates"], "ask", ask["answers"])
except KeyboardInterrupt:
@@ -1063,6 +1134,8 @@ class TransferApp(App):
parts.append(f"ETA {int(st.eta_seconds // 60)}:{int(st.eta_seconds % 60):02d}")
if st.files_total:
parts.append(f"文件 {st.files_done}/{st.files_total}")
self.query_one("#file-bar", ProgressBar).update(
progress=min(100.0, st.files_done / st.files_total * 100))
self.query_one("#stats-line", Static).update(" ".join(parts))
def _on_file(self, f: FileProgress) -> None: