v1.8.0: file manager improvements
- SFTP cleanup on app close and language switch - Windows drive selector in local panel - Browse and Refresh buttons for local panel - Recursive upload/download/delete of folders - Drag-and-drop between local and remote panels - Sudo mode toggle for privileged file operations - New i18n keys for EN/RU/ZH Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
core/i18n.py
27
core/i18n.py
@@ -275,6 +275,15 @@ _EN = {
|
||||
"parent_dir": "Parent directory",
|
||||
"refresh_files": "Refresh",
|
||||
"items_count": "{count} items",
|
||||
"sudo_mode": "Sudo",
|
||||
"try_sudo_hint": "Try enabling Sudo mode",
|
||||
"uploading_dir": "Uploading folder: {name}",
|
||||
"downloading_dir": "Downloading folder: {name}",
|
||||
"transfer_file_progress": "File {cur}/{total}: {name}",
|
||||
"drop_to_upload": "Drop to upload",
|
||||
"drop_to_download": "Drop to download",
|
||||
"recursive_delete_confirm": "Delete folder '{name}' and all contents?",
|
||||
"drive": "Drive",
|
||||
}
|
||||
|
||||
_RU = {
|
||||
@@ -527,6 +536,15 @@ _RU = {
|
||||
"parent_dir": "Родительская папка",
|
||||
"refresh_files": "Обновить",
|
||||
"items_count": "{count} элементов",
|
||||
"sudo_mode": "Sudo",
|
||||
"try_sudo_hint": "Попробуйте включить Sudo",
|
||||
"uploading_dir": "Загрузка папки: {name}",
|
||||
"downloading_dir": "Скачивание папки: {name}",
|
||||
"transfer_file_progress": "Файл {cur}/{total}: {name}",
|
||||
"drop_to_upload": "Отпустите для загрузки",
|
||||
"drop_to_download": "Отпустите для скачивания",
|
||||
"recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?",
|
||||
"drive": "Диск",
|
||||
}
|
||||
|
||||
_ZH = {
|
||||
@@ -779,6 +797,15 @@ _ZH = {
|
||||
"parent_dir": "上级目录",
|
||||
"refresh_files": "刷新",
|
||||
"items_count": "{count} 个项目",
|
||||
"sudo_mode": "Sudo",
|
||||
"try_sudo_hint": "尝试启用Sudo模式",
|
||||
"uploading_dir": "上传文件夹: {name}",
|
||||
"downloading_dir": "下载文件夹: {name}",
|
||||
"transfer_file_progress": "文件 {cur}/{total}: {name}",
|
||||
"drop_to_upload": "释放以上传",
|
||||
"drop_to_download": "释放以下载",
|
||||
"recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?",
|
||||
"drive": "驱动器",
|
||||
}
|
||||
|
||||
_TRANSLATIONS = {
|
||||
|
||||
@@ -316,6 +316,7 @@ class SFTPSession:
|
||||
self.key_path = key_path
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
self._sftp: paramiko.SFTPClient | None = None
|
||||
self.sudo_mode: bool = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
@@ -380,6 +381,166 @@ class SFTPSession:
|
||||
def normalize(self, path: str) -> str:
|
||||
return self._sftp.normalize(path)
|
||||
|
||||
# ── Recursive operations ──
|
||||
|
||||
def upload_dir(self, local_dir: str, remote_dir: str, progress_cb=None, file_cb=None):
|
||||
"""Recursively upload a local directory to remote."""
|
||||
all_files = []
|
||||
for root, dirs, files in os.walk(local_dir):
|
||||
rel = os.path.relpath(root, local_dir)
|
||||
remote_sub = remote_dir if rel == "." else remote_dir + "/" + rel.replace("\\", "/")
|
||||
try:
|
||||
self._sftp.mkdir(remote_sub)
|
||||
except IOError:
|
||||
pass
|
||||
for f in files:
|
||||
local_file = os.path.join(root, f)
|
||||
remote_file = remote_sub + "/" + f
|
||||
all_files.append((local_file, remote_file))
|
||||
|
||||
for idx, (local_file, remote_file) in enumerate(all_files):
|
||||
if file_cb:
|
||||
file_cb(idx + 1, len(all_files), os.path.basename(local_file))
|
||||
self._sftp.put(local_file, remote_file, callback=progress_cb)
|
||||
|
||||
def download_dir(self, remote_dir: str, local_dir: str, progress_cb=None, file_cb=None):
|
||||
"""Recursively download a remote directory to local."""
|
||||
all_files = []
|
||||
self._walk_remote(remote_dir, remote_dir, all_files)
|
||||
for idx, (remote_file, rel_path) in enumerate(all_files):
|
||||
local_file = os.path.join(local_dir, rel_path)
|
||||
os.makedirs(os.path.dirname(local_file), exist_ok=True)
|
||||
if file_cb:
|
||||
file_cb(idx + 1, len(all_files), os.path.basename(remote_file))
|
||||
self._sftp.get(remote_file, local_file, callback=progress_cb)
|
||||
|
||||
def _walk_remote(self, base: str, current: str, result: list):
|
||||
"""Recursively walk remote directory, collecting (abs_path, relative_path) tuples."""
|
||||
import stat as stat_mod
|
||||
for attr in self._sftp.listdir_attr(current):
|
||||
full = current + "/" + attr.filename
|
||||
rel = full[len(base):].lstrip("/")
|
||||
if stat_mod.S_ISDIR(attr.st_mode or 0):
|
||||
self._walk_remote(base, full, result)
|
||||
else:
|
||||
result.append((full, rel.replace("/", os.sep)))
|
||||
|
||||
def rmdir_recursive(self, path: str):
|
||||
"""Recursively delete a remote directory."""
|
||||
import stat as stat_mod
|
||||
for attr in self._sftp.listdir_attr(path):
|
||||
child = path + "/" + attr.filename
|
||||
if stat_mod.S_ISDIR(attr.st_mode or 0):
|
||||
self.rmdir_recursive(child)
|
||||
else:
|
||||
self._sftp.remove(child)
|
||||
self._sftp.rmdir(path)
|
||||
|
||||
# ── Sudo operations ──
|
||||
|
||||
def exec_command(self, cmd: str) -> str:
|
||||
"""Execute command via SSH with optional sudo wrapper."""
|
||||
if not self._client:
|
||||
raise Exception("Not connected")
|
||||
password = self.server.get("password", "")
|
||||
user = self.server.get("user", "root")
|
||||
if self.sudo_mode and user != "root":
|
||||
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(cmd)}"
|
||||
else:
|
||||
full_cmd = cmd
|
||||
stdin, stdout, stderr = self._client.exec_command(full_cmd, timeout=30)
|
||||
if self.sudo_mode and user != "root" and password:
|
||||
stdin.write(password + "\n")
|
||||
stdin.flush()
|
||||
return stdout.read().decode("utf-8", errors="replace")
|
||||
|
||||
def listdir_attr_sudo(self, path: str) -> list:
|
||||
"""List directory using sudo ls -la, returning objects with .filename/.st_size/.st_mtime/.st_mode."""
|
||||
output = self.exec_command(f"ls -la --time-style=+%s {_shell_quote(path)}")
|
||||
results = []
|
||||
for line in output.strip().splitlines():
|
||||
if line.startswith("total "):
|
||||
continue
|
||||
parts = line.split(None, 7)
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
perms = parts[0]
|
||||
if perms.startswith("d") or perms.startswith("l") or perms.startswith("-"):
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
size_str = parts[4]
|
||||
mtime_str = parts[5]
|
||||
# parts[6] may be time or name depending on format
|
||||
# With --time-style=+%s: perms links owner group size epoch name
|
||||
name = parts[6] if len(parts) == 7 else parts[7]
|
||||
if name in (".", ".."):
|
||||
continue
|
||||
try:
|
||||
size = int(size_str)
|
||||
except ValueError:
|
||||
size = 0
|
||||
try:
|
||||
mtime = int(mtime_str)
|
||||
except ValueError:
|
||||
mtime = 0
|
||||
mode = _parse_ls_perms(perms)
|
||||
entry = _SudoFileAttr(name, size, mtime, mode)
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
def upload_sudo(self, local_path: str, remote_path: str, progress_cb=None):
|
||||
"""Upload via SFTP to /tmp then sudo mv to destination."""
|
||||
import random
|
||||
tmp_name = f"/tmp/.sm_upload_{random.randint(100000, 999999)}"
|
||||
if progress_cb:
|
||||
self._sftp.put(local_path, tmp_name, callback=progress_cb)
|
||||
else:
|
||||
self._sftp.put(local_path, tmp_name)
|
||||
self.exec_command(f"mv {_shell_quote(tmp_name)} {_shell_quote(remote_path)}")
|
||||
|
||||
def download_sudo(self, remote_path: str, local_path: str, progress_cb=None):
|
||||
"""Copy via sudo to /tmp then download via SFTP."""
|
||||
import random
|
||||
tmp_name = f"/tmp/.sm_download_{random.randint(100000, 999999)}"
|
||||
self.exec_command(f"cp {_shell_quote(remote_path)} {_shell_quote(tmp_name)} && chmod 644 {_shell_quote(tmp_name)}")
|
||||
try:
|
||||
if progress_cb:
|
||||
self._sftp.get(tmp_name, local_path, callback=progress_cb)
|
||||
else:
|
||||
self._sftp.get(tmp_name, local_path)
|
||||
finally:
|
||||
self.exec_command(f"rm -f {_shell_quote(tmp_name)}")
|
||||
|
||||
|
||||
class _SudoFileAttr:
|
||||
"""Mimics paramiko SFTPAttributes for sudo ls output."""
|
||||
def __init__(self, filename: str, st_size: int, st_mtime: int, st_mode: int):
|
||||
self.filename = filename
|
||||
self.st_size = st_size
|
||||
self.st_mtime = st_mtime
|
||||
self.st_mode = st_mode
|
||||
|
||||
|
||||
def _parse_ls_perms(perms: str) -> int:
|
||||
"""Parse ls -l permission string like 'drwxr-xr-x' into stat mode int."""
|
||||
import stat as stat_mod
|
||||
mode = 0
|
||||
if perms[0] == "d":
|
||||
mode |= stat_mod.S_IFDIR
|
||||
elif perms[0] == "l":
|
||||
mode |= stat_mod.S_IFLNK
|
||||
else:
|
||||
mode |= stat_mod.S_IFREG
|
||||
mapping = "rwxrwxrwx"
|
||||
bits = [stat_mod.S_IRUSR, stat_mod.S_IWUSR, stat_mod.S_IXUSR,
|
||||
stat_mod.S_IRGRP, stat_mod.S_IWGRP, stat_mod.S_IXGRP,
|
||||
stat_mod.S_IROTH, stat_mod.S_IWOTH, stat_mod.S_IXOTH]
|
||||
for i, (ch, bit) in enumerate(zip(perms[1:10], bits)):
|
||||
if ch != "-":
|
||||
mode |= bit
|
||||
return mode
|
||||
|
||||
|
||||
def _shell_quote(s: str) -> str:
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
|
||||
Reference in New Issue
Block a user