init
Some checks failed
Build and Release / Build on macos-latest (push) Has been cancelled
Build and Release / Build on ubuntu-latest (push) Has been cancelled
Build and Release / Build on windows-latest (push) Has been cancelled
Build and Release / release (push) Has been cancelled

This commit is contained in:
Matt.Ma
2025-09-09 15:06:18 +08:00
commit 6d149cefed
6 changed files with 499 additions and 0 deletions

58
.github/workflows/release.yaml vendored Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
prompt_toolkit==3.0.52

328
rsync-tui.py Normal file
View 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()