init
Some checks failed
Some checks failed
This commit is contained in:
58
.github/workflows/release.yaml
vendored
Normal file
58
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*' # 打 tag 触发,例如 v1.0.0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build on ${{ matrix.os }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
- name: Build executable
|
||||||
|
run: |
|
||||||
|
pyinstaller --onefile rsync-tui.py
|
||||||
|
ls dist
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rsync-tui-${{ runner.os }}
|
||||||
|
path: dist/*
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/**/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
*.log
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# VSCode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# 环境/依赖
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.app
|
||||||
|
*.dmg
|
||||||
|
*.pkg
|
||||||
|
*.deb
|
||||||
|
*.rpm
|
||||||
|
*.tar.gz
|
||||||
|
*.whl
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 其他
|
||||||
|
.idea/
|
||||||
|
*.bak
|
||||||
|
*.old
|
||||||
|
|
||||||
|
# 你可以根据实际情况补充更多忽略项
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Matt
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# rsync-tui
|
||||||
|
|
||||||
|
一个基于 Python 和 prompt_toolkit 的跨平台远程文件管理器,支持 SSH 目录浏览、批量 rsync 下载、断点续传、实时进度输出。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
- 远程 SSH 目录浏览与文件选择
|
||||||
|
- 批量下载,支持断点续传(rsync --partial)
|
||||||
|
- 实时显示 rsync 原始输出和进度
|
||||||
|
- 支持 Linux/macOS/WSL
|
||||||
|
- 纯命令行 TUI,键盘友好
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 安装依赖:
|
||||||
|
```bash
|
||||||
|
pip install prompt_toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 运行:
|
||||||
|
```bash
|
||||||
|
python rsync-tui.py <远程主机IP或域名> --user <用户名> --port <端口>
|
||||||
|
```
|
||||||
|
例如:
|
||||||
|
```bash
|
||||||
|
python rsync-tui.py 192.168.1.100 --user root --port 22
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 操作说明:
|
||||||
|
- 上下方向键:移动光标
|
||||||
|
- 空格:选择/取消文件
|
||||||
|
- 回车:进入目录/返回上级
|
||||||
|
- D:批量下载选中文件/文件夹
|
||||||
|
- Q:退出并强制终止所有传输
|
||||||
|
- 右侧窗口实时显示 rsync 输出
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
- Python 3.7+
|
||||||
|
- prompt_toolkit
|
||||||
|
- rsync (本地和远端均需安装)
|
||||||
|
- ssh
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
MIT License
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
prompt_toolkit==3.0.52
|
||||||
328
rsync-tui.py
Normal file
328
rsync-tui.py
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
|
||||||
|
# rsync-tui: 远程文件管理与断点续传 TUI 工具
|
||||||
|
# Author: Matt (https://github.com/你的用户名)
|
||||||
|
# License: MIT
|
||||||
|
#
|
||||||
|
# 一个基于 prompt_toolkit 的跨平台远程文件管理器,支持 SSH 目录浏览、批量 rsync 下载、断点续传、实时进度输出。
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from prompt_toolkit.application import Application
|
||||||
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.layout import Layout, HSplit, VSplit, Window
|
||||||
|
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
|
from prompt_toolkit.widgets import Frame
|
||||||
|
|
||||||
|
# 检查远端是否安装rsync,如无则自动安装
|
||||||
|
|
||||||
|
def check_and_install_rsync(user, host, port):
|
||||||
|
"""检查远端是否安装rsync,如无则自动尝试安装。"""
|
||||||
|
check_cmd = [
|
||||||
|
'ssh', '-p', str(port), f'{user}@{host}', 'command -v rsync'
|
||||||
|
]
|
||||||
|
result = subprocess.run(check_cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
# 尝试自动安装(支持apt/yum)
|
||||||
|
for install in [
|
||||||
|
'sudo apt-get update && sudo apt-get install -y rsync',
|
||||||
|
'sudo yum install -y rsync'
|
||||||
|
]:
|
||||||
|
install_cmd = [
|
||||||
|
'ssh', '-p', str(port), f'{user}@{host}', install
|
||||||
|
]
|
||||||
|
res = subprocess.run(install_cmd, capture_output=True, text=True)
|
||||||
|
if res.returncode == 0:
|
||||||
|
return True
|
||||||
|
print('自动安装rsync失败,请手动安装')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def get_remote_home(user, host, port):
|
||||||
|
"""获取远程主机的家目录。"""
|
||||||
|
ssh_cmd = [
|
||||||
|
'ssh', '-p', str(port), f'{user}@{host}', 'echo $HOME'
|
||||||
|
]
|
||||||
|
result = subprocess.run(ssh_cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return os.path.normpath(result.stdout.strip())
|
||||||
|
return '/'
|
||||||
|
|
||||||
|
# 解析ls输出
|
||||||
|
|
||||||
|
def parse_ls_output(output):
|
||||||
|
"""解析ls -lA输出,返回文件条目列表。"""
|
||||||
|
lines = output.strip().split('\n')
|
||||||
|
entries = []
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith('total'):
|
||||||
|
continue
|
||||||
|
parts = line.split(None, 7)
|
||||||
|
if len(parts) < 8:
|
||||||
|
continue
|
||||||
|
perms, links, user, group, size, date, time, name = parts
|
||||||
|
ftype = perms[0]
|
||||||
|
entries.append({
|
||||||
|
'ftype': ftype,
|
||||||
|
'perms': perms,
|
||||||
|
'user': user,
|
||||||
|
'group': group,
|
||||||
|
'size': size,
|
||||||
|
'date': date,
|
||||||
|
'time': time,
|
||||||
|
'name': name
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
|
||||||
|
# 获取远程目录内容
|
||||||
|
|
||||||
|
def get_entries(user, host, port, path):
|
||||||
|
"""获取远程目录内容,返回文件条目列表。"""
|
||||||
|
ls_bins = ['/bin/ls', '/usr/bin/ls', 'ls']
|
||||||
|
ls_cmds = []
|
||||||
|
for bin in ls_bins:
|
||||||
|
ls_cmds.append(f'{bin} -lA --time-style=long-iso {path}')
|
||||||
|
ls_cmds.append(f'{bin} -lA {path}')
|
||||||
|
result = None
|
||||||
|
for ls_cmd in ls_cmds:
|
||||||
|
ssh_cmd = [
|
||||||
|
'ssh', '-p', str(port), f'{user}@{host}', ls_cmd
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(ssh_cmd, check=True, capture_output=True, text=True)
|
||||||
|
break
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
result = None
|
||||||
|
continue
|
||||||
|
if not result:
|
||||||
|
return []
|
||||||
|
entries = parse_ls_output(result.stdout)
|
||||||
|
if path != '/':
|
||||||
|
entries = [{
|
||||||
|
'ftype': 'd', 'perms': 'drwxr-xr-x', 'user': '', 'group': '', 'size': '', 'date': '', 'time': '', 'name': '..'
|
||||||
|
}] + entries
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def rsync_pull(user, host, remote_path, local_path, port, follow_symlinks, set_message_threadsafe, active_procs=None, append_output=None):
|
||||||
|
"""
|
||||||
|
使用rsync拉取远程文件/目录,支持断点续传和实时进度。
|
||||||
|
"""
|
||||||
|
remote_path = os.path.normpath(remote_path)
|
||||||
|
local_path = os.path.normpath(local_path)
|
||||||
|
ssh_cmd = f"ssh -p {port} -o LogLevel=ERROR -o StreamLocalBindUnlink=yes -o ServerAliveInterval=30"
|
||||||
|
rsync_cmd = [
|
||||||
|
'stdbuf', '-oL', 'rsync', '-avz', '--progress', '--partial', '-e', ssh_cmd
|
||||||
|
]
|
||||||
|
if follow_symlinks:
|
||||||
|
rsync_cmd.append('-L')
|
||||||
|
rsync_cmd.append(f'{user}@{host}:{remote_path}')
|
||||||
|
rsync_cmd.append(local_path)
|
||||||
|
def parse_file_progress(line):
|
||||||
|
m = re.search(r'to-chk=(\d+)/(\d+)', line)
|
||||||
|
if m:
|
||||||
|
remain = int(m.group(1))
|
||||||
|
total = int(m.group(2))
|
||||||
|
done = total - remain
|
||||||
|
return f'文件进度: {done}/{total}'
|
||||||
|
return None
|
||||||
|
proc = subprocess.Popen(rsync_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, preexec_fn=os.setsid)
|
||||||
|
if active_procs is not None:
|
||||||
|
active_procs.append(proc)
|
||||||
|
def reader():
|
||||||
|
for line in proc.stdout:
|
||||||
|
if append_output:
|
||||||
|
append_output(line.rstrip())
|
||||||
|
msg = parse_file_progress(line)
|
||||||
|
if msg:
|
||||||
|
set_message_threadsafe(msg)
|
||||||
|
t = threading.Thread(target=reader, daemon=True)
|
||||||
|
t.start()
|
||||||
|
while t.is_alive() or proc.poll() is None:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
t.join()
|
||||||
|
rc = proc.wait()
|
||||||
|
if active_procs is not None:
|
||||||
|
try:
|
||||||
|
active_procs.remove(proc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if rc == 0:
|
||||||
|
set_message_threadsafe('传输完毕')
|
||||||
|
else:
|
||||||
|
set_message_threadsafe('同步失败')
|
||||||
|
|
||||||
|
# 交互式浏览与选择
|
||||||
|
|
||||||
|
async def interactive_browse(user, host, port, start_path):
|
||||||
|
"""
|
||||||
|
主交互界面,支持远程目录浏览、文件选择、批量下载、实时进度输出。
|
||||||
|
"""
|
||||||
|
import functools
|
||||||
|
cwd = start_path
|
||||||
|
selected = 0
|
||||||
|
selected_files = set()
|
||||||
|
message = ['']
|
||||||
|
active_procs = []
|
||||||
|
output_lines = []
|
||||||
|
style = Style.from_dict({
|
||||||
|
'selected': 'reverse',
|
||||||
|
'directory': 'ansiblue',
|
||||||
|
'symlink': 'ansimagenta',
|
||||||
|
'file': '',
|
||||||
|
'marked': 'bold underline',
|
||||||
|
'message': 'bg:#444444 #ffffff',
|
||||||
|
'output': 'bg:#222222 #00ff00',
|
||||||
|
})
|
||||||
|
def get_lines(entries, selected, marked):
|
||||||
|
lines = []
|
||||||
|
for idx, entry in enumerate(entries):
|
||||||
|
t = entry['ftype']
|
||||||
|
n = entry['name']
|
||||||
|
color = 'class:file'
|
||||||
|
if t == 'd':
|
||||||
|
color = 'class:directory'
|
||||||
|
elif t == 'l':
|
||||||
|
color = 'class:symlink'
|
||||||
|
prefix = '➤ ' if idx == selected else ' '
|
||||||
|
mark = '[*] ' if n in marked else ' '
|
||||||
|
style_line = 'class:marked' if n in marked else color
|
||||||
|
if idx == selected:
|
||||||
|
lines.append(('class:selected', prefix + mark + n + '\n'))
|
||||||
|
else:
|
||||||
|
lines.append((style_line, prefix + mark + n + '\n'))
|
||||||
|
return FormattedText(lines)
|
||||||
|
entries = get_entries(user, host, port, cwd)
|
||||||
|
kb = KeyBindings()
|
||||||
|
body_control = FormattedTextControl(lambda: get_lines(entries, selected, selected_files))
|
||||||
|
message_control = FormattedTextControl(lambda: message[0], style='class:message')
|
||||||
|
class OutputControl(FormattedTextControl):
|
||||||
|
def __init__(self, lines):
|
||||||
|
super().__init__(self.get_text, style='class:output')
|
||||||
|
self.lines = lines
|
||||||
|
def get_text(self):
|
||||||
|
return '\n'.join(self.lines)
|
||||||
|
output_control = OutputControl(output_lines)
|
||||||
|
body_window = Window(content=body_control, always_hide_cursor=False)
|
||||||
|
message_window = Window(content=message_control, height=1, style='class:message')
|
||||||
|
output_window = Window(content=output_control, width=60, style='class:output', wrap_lines=True)
|
||||||
|
def refresh(entries, selected, marked):
|
||||||
|
body_control.text = get_lines(entries, selected, marked)
|
||||||
|
message_control.text = message[0]
|
||||||
|
def set_message_threadsafe(msg):
|
||||||
|
message[0] = msg
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.call_soon_threadsafe(functools.partial(refresh, entries, selected, selected_files))
|
||||||
|
except Exception:
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
def append_output(line):
|
||||||
|
output_lines.append(line)
|
||||||
|
if len(output_lines) > 30:
|
||||||
|
del output_lines[0]
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.call_soon_threadsafe(app.invalidate)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
def update_entries():
|
||||||
|
nonlocal entries
|
||||||
|
entries = get_entries(user, host, port, cwd)
|
||||||
|
@kb.add('up')
|
||||||
|
def _(event):
|
||||||
|
nonlocal selected
|
||||||
|
if selected > 0:
|
||||||
|
selected -= 1
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
@kb.add('down')
|
||||||
|
def _(event):
|
||||||
|
nonlocal selected
|
||||||
|
if selected < len(entries) - 1:
|
||||||
|
selected += 1
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
@kb.add('space')
|
||||||
|
def _(event):
|
||||||
|
entry = entries[selected]
|
||||||
|
n = entry['name']
|
||||||
|
if n in selected_files:
|
||||||
|
selected_files.remove(n)
|
||||||
|
else:
|
||||||
|
selected_files.add(n)
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
@kb.add('enter')
|
||||||
|
def _(event):
|
||||||
|
nonlocal cwd, entries, selected
|
||||||
|
entry = entries[selected]
|
||||||
|
if entry['name'] == '..':
|
||||||
|
cwd = os.path.dirname(cwd.rstrip('/')) or '/'
|
||||||
|
update_entries()
|
||||||
|
selected = 0
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
elif entry['ftype'] == 'd':
|
||||||
|
cwd = os.path.join(cwd, entry['name'])
|
||||||
|
update_entries()
|
||||||
|
selected = 0
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
@kb.add('d')
|
||||||
|
async def _(event):
|
||||||
|
for n in selected_files:
|
||||||
|
remote_path = os.path.join(cwd, n)
|
||||||
|
local_path = '.'
|
||||||
|
follow_symlinks = False
|
||||||
|
await rsync_pull(user, host, remote_path, local_path, port, follow_symlinks, set_message_threadsafe, active_procs, append_output)
|
||||||
|
selected_files.clear()
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
@kb.add('<any>')
|
||||||
|
def _(event):
|
||||||
|
if message[0]:
|
||||||
|
set_message_threadsafe('')
|
||||||
|
@kb.add('q')
|
||||||
|
def _(event):
|
||||||
|
for proc in active_procs:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
event.app.exit()
|
||||||
|
import os as _os
|
||||||
|
_os._exit(0)
|
||||||
|
root_container = Frame(HSplit([
|
||||||
|
VSplit([
|
||||||
|
body_window,
|
||||||
|
output_window
|
||||||
|
]),
|
||||||
|
message_window
|
||||||
|
]), title=lambda: f'远程目录: {cwd} (空格选中, 回车进目录, D批量下载, Q退出)')
|
||||||
|
layout = Layout(root_container)
|
||||||
|
app = Application(layout=layout, key_bindings=kb, style=style, full_screen=True)
|
||||||
|
refresh(entries, selected, selected_files)
|
||||||
|
# 定时器强制刷新UI,防止output_control未被重绘
|
||||||
|
import threading as _threading
|
||||||
|
def periodic_refresh():
|
||||||
|
try:
|
||||||
|
app.invalidate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_threading.Timer(0.2, periodic_refresh).start()
|
||||||
|
periodic_refresh()
|
||||||
|
await app.run_async()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""命令行入口。"""
|
||||||
|
parser = argparse.ArgumentParser(description='rsync-tui: 远程文件管理器')
|
||||||
|
parser.add_argument('host', help='远程主机IP或域名')
|
||||||
|
parser.add_argument('--user', default='root', help='ssh用户名,默认root')
|
||||||
|
parser.add_argument('--port', type=int, default=22, help='ssh端口,默认22')
|
||||||
|
args = parser.parse_args()
|
||||||
|
check_and_install_rsync(args.user, args.host, args.port)
|
||||||
|
home = get_remote_home(args.user, args.host, args.port)
|
||||||
|
asyncio.run(interactive_browse(args.user, args.host, args.port, home))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user