feat: TUI 命令 stop/pause/continue + 单文件进度条改为文件计数轮询
This commit is contained in:
1 parent
7ab32bad24
commit
81857d6343
3 files changed
+145
-9
No files matched your search
+79
-6
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user