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:
chrome-storm-c442
2026-02-23 16:30:56 -05:00
parent a77ca6fee7
commit 3e9aeababe
7 changed files with 528 additions and 103 deletions

View File

@@ -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("'", "'\\''") + "'"