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("'", "'\\''") + "'"
|
||||
|
||||
@@ -171,8 +171,9 @@ class App(ctk.CTk):
|
||||
# Use provided key or default to first tab
|
||||
current_key = restore_tab_key or self._tab_keys[0]
|
||||
|
||||
# Disconnect terminal before destroying tabs
|
||||
# Disconnect terminal and SFTP before destroying tabs
|
||||
self.terminal_tab._disconnect()
|
||||
self.files_tab._disconnect_sftp()
|
||||
|
||||
# Detach tab contents
|
||||
self.terminal_tab.pack_forget()
|
||||
@@ -227,5 +228,6 @@ class App(ctk.CTk):
|
||||
|
||||
def _on_close(self):
|
||||
self.terminal_tab._disconnect()
|
||||
self.files_tab._disconnect_sftp()
|
||||
self.checker.stop()
|
||||
self.destroy()
|
||||
|
||||
@@ -3,10 +3,12 @@ Files tab — dual-pane SFTP file manager.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import string
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from tkinter import messagebox
|
||||
from tkinter import messagebox, filedialog
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
@@ -45,6 +47,16 @@ def _format_perm(mode: int) -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _get_windows_drives() -> list[str]:
|
||||
"""Return list of available drive letters on Windows (e.g. ['C:\\', 'D:\\'])."""
|
||||
drives = []
|
||||
for letter in string.ascii_uppercase:
|
||||
drive = f"{letter}:\\"
|
||||
if os.path.exists(drive):
|
||||
drives.append(drive)
|
||||
return drives
|
||||
|
||||
|
||||
class FilesTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
@@ -56,6 +68,7 @@ class FilesTab(ctk.CTkFrame):
|
||||
self._local_history: list[str] = []
|
||||
self._remote_history: list[str] = []
|
||||
self._transferring = False
|
||||
self._sudo_var = ctk.BooleanVar(value=False)
|
||||
|
||||
self._build_ui()
|
||||
self._refresh_local()
|
||||
@@ -87,6 +100,33 @@ class FilesTab(ctk.CTkFrame):
|
||||
)
|
||||
self._local_up_btn.pack(side="left", padx=2)
|
||||
|
||||
# Local refresh button
|
||||
self._local_refresh_btn = ctk.CTkButton(
|
||||
left_header, text="\u21BB", width=30, height=28,
|
||||
command=self._refresh_local,
|
||||
)
|
||||
self._local_refresh_btn.pack(side="left", padx=2)
|
||||
|
||||
# Browse button
|
||||
self._browse_btn = ctk.CTkButton(
|
||||
left_header, text=t("browse"), width=60, height=28,
|
||||
command=self._browse_local,
|
||||
)
|
||||
self._browse_btn.pack(side="left", padx=2)
|
||||
|
||||
# Windows drive selector
|
||||
self._drive_menu = None
|
||||
if platform.system() == "Windows":
|
||||
drives = _get_windows_drives()
|
||||
if drives:
|
||||
current_drive = os.path.splitdrive(self._local_path)[0] + "\\"
|
||||
self._drive_var = ctk.StringVar(value=current_drive)
|
||||
self._drive_menu = ctk.CTkOptionMenu(
|
||||
left_header, values=drives, variable=self._drive_var,
|
||||
width=65, height=28, command=self._on_drive_change,
|
||||
)
|
||||
self._drive_menu.pack(side="left", padx=2)
|
||||
|
||||
self._local_path_entry = ctk.CTkEntry(left_header, height=28)
|
||||
self._local_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0))
|
||||
self._local_path_entry.bind("<Return>", lambda e: self._local_go_to_path())
|
||||
@@ -95,6 +135,7 @@ class FilesTab(ctk.CTkFrame):
|
||||
left_pane,
|
||||
columns=[(t("name_col"), 220), (t("size_col"), 80), (t("date_col"), 110)],
|
||||
on_navigate=self._navigate_local,
|
||||
on_drop=self._on_drop_to_local,
|
||||
)
|
||||
self._local_list.pack(fill="both", expand=True)
|
||||
|
||||
@@ -143,6 +184,7 @@ class FilesTab(ctk.CTkFrame):
|
||||
(t("date_col"), 100), (t("perm_col"), 80),
|
||||
],
|
||||
on_navigate=self._navigate_remote,
|
||||
on_drop=self._on_drop_to_remote,
|
||||
)
|
||||
self._remote_list.pack(fill="both", expand=True)
|
||||
|
||||
@@ -152,6 +194,10 @@ class FilesTab(ctk.CTkFrame):
|
||||
)
|
||||
self._remote_status.pack(fill="x", pady=(2, 0))
|
||||
|
||||
# Wire drag-and-drop partners
|
||||
self._local_list.set_drag_partner(self._remote_list)
|
||||
self._remote_list.set_drag_partner(self._local_list)
|
||||
|
||||
# === Toolbar ===
|
||||
toolbar = ctk.CTkFrame(self, fg_color="transparent")
|
||||
toolbar.pack(fill="x", padx=10, pady=4)
|
||||
@@ -190,6 +236,16 @@ class FilesTab(ctk.CTkFrame):
|
||||
)
|
||||
self._rename_btn.pack(side="left", padx=4)
|
||||
|
||||
# Sudo toggle
|
||||
sep2 = ctk.CTkFrame(toolbar, width=2, height=24, fg_color="gray40")
|
||||
sep2.pack(side="left", padx=8)
|
||||
|
||||
self._sudo_switch = ctk.CTkSwitch(
|
||||
toolbar, text=t("sudo_mode"), variable=self._sudo_var,
|
||||
width=50, height=24, command=self._on_sudo_toggle,
|
||||
)
|
||||
self._sudo_switch.pack(side="left", padx=4)
|
||||
|
||||
self._set_remote_buttons_state("disabled")
|
||||
|
||||
# === Transfer / Log area ===
|
||||
@@ -248,6 +304,7 @@ class FilesTab(ctk.CTkFrame):
|
||||
|
||||
def _on_sftp_connected(self, sftp: SFTPSession, home: str):
|
||||
self._sftp = sftp
|
||||
self._sftp.sudo_mode = self._sudo_var.get()
|
||||
self._remote_path = home
|
||||
self._remote_history.clear()
|
||||
self._remote_status.configure(
|
||||
@@ -268,11 +325,36 @@ class FilesTab(ctk.CTkFrame):
|
||||
pass
|
||||
self._sftp = None
|
||||
|
||||
# ── Browse / Drive ──
|
||||
|
||||
def _browse_local(self):
|
||||
path = filedialog.askdirectory(initialdir=self._local_path)
|
||||
if path:
|
||||
self._local_history.append(self._local_path)
|
||||
self._local_path = os.path.normpath(path)
|
||||
self._refresh_local()
|
||||
|
||||
def _on_drive_change(self, drive: str):
|
||||
self._local_history.append(self._local_path)
|
||||
self._local_path = drive
|
||||
self._refresh_local()
|
||||
|
||||
def _on_sudo_toggle(self):
|
||||
if self._sftp:
|
||||
self._sftp.sudo_mode = self._sudo_var.get()
|
||||
self._refresh_remote()
|
||||
|
||||
# ── Local navigation ──
|
||||
|
||||
def _refresh_local(self):
|
||||
self._local_path_entry.delete(0, "end")
|
||||
self._local_path_entry.insert(0, self._local_path)
|
||||
|
||||
# Sync drive selector
|
||||
if self._drive_menu and platform.system() == "Windows":
|
||||
current_drive = os.path.splitdrive(self._local_path)[0] + "\\"
|
||||
self._drive_var.set(current_drive)
|
||||
|
||||
try:
|
||||
entries = os.listdir(self._local_path)
|
||||
except PermissionError:
|
||||
@@ -344,6 +426,12 @@ class FilesTab(ctk.CTkFrame):
|
||||
|
||||
def _do():
|
||||
try:
|
||||
if self._sftp.sudo_mode:
|
||||
try:
|
||||
attrs = self._sftp.listdir_attr_sudo(self._remote_path)
|
||||
except Exception:
|
||||
attrs = self._sftp.listdir_attr(self._remote_path)
|
||||
else:
|
||||
attrs = self._sftp.listdir_attr(self._remote_path)
|
||||
items = []
|
||||
# Parent dir
|
||||
@@ -361,6 +449,9 @@ class FilesTab(ctk.CTkFrame):
|
||||
"is_dir": is_dir,
|
||||
})
|
||||
self.after(0, lambda: self._populate_remote(items))
|
||||
except PermissionError as e:
|
||||
hint = f"\n{t('try_sudo_hint')}" if not self._sftp.sudo_mode else ""
|
||||
self.after(0, lambda: self._on_sftp_error(str(e) + hint))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._on_sftp_error(str(e)))
|
||||
|
||||
@@ -405,15 +496,21 @@ class FilesTab(ctk.CTkFrame):
|
||||
self._remote_path = path
|
||||
self._refresh_remote()
|
||||
|
||||
# ── File operations ──
|
||||
# ── Remote path helper ──
|
||||
|
||||
def _upload_selected(self):
|
||||
def _remote_join(self, name: str) -> str:
|
||||
if self._remote_path == "/":
|
||||
return "/" + name
|
||||
return self._remote_path + "/" + name
|
||||
|
||||
# ── Upload / Download (shared logic) ──
|
||||
|
||||
def _do_upload(self, items: list[dict]):
|
||||
"""Upload files and folders from local to remote. Runs in background thread."""
|
||||
if not self._sftp or not self._sftp.connected or self._transferring:
|
||||
return
|
||||
selected = self._local_list.get_selected()
|
||||
files = [s for s in selected if not s.get("is_dir") and s["name"] != ".."]
|
||||
if not files:
|
||||
self._log_msg(t("no_server_selected"))
|
||||
items = [s for s in items if s["name"] != ".."]
|
||||
if not items:
|
||||
return
|
||||
|
||||
self._transferring = True
|
||||
@@ -421,14 +518,28 @@ class FilesTab(ctk.CTkFrame):
|
||||
self._download_btn.configure(state="disabled")
|
||||
|
||||
def _do():
|
||||
for item in files:
|
||||
for item in items:
|
||||
name = item["name"]
|
||||
local = os.path.join(self._local_path, name)
|
||||
if self._remote_path == "/":
|
||||
remote = "/" + name
|
||||
else:
|
||||
remote = self._remote_path + "/" + name
|
||||
remote = self._remote_join(name)
|
||||
try:
|
||||
if item.get("is_dir"):
|
||||
self.after(0, lambda n=name: self._transfer_label.configure(
|
||||
text=t("uploading_dir").format(name=n)
|
||||
))
|
||||
|
||||
def _file_cb(cur, total, fname, n=name):
|
||||
self.after(0, lambda c=cur, tt=total, fn=fname:
|
||||
self._transfer_label.configure(
|
||||
text=t("transfer_file_progress").format(cur=c, total=tt, name=fn)
|
||||
))
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total > 0:
|
||||
self.after(0, lambda f=transferred/total: self._progress.set(f))
|
||||
|
||||
self._sftp.upload_dir(local, remote, progress_cb=_progress, file_cb=_file_cb)
|
||||
else:
|
||||
file_size = os.path.getsize(local)
|
||||
self.after(0, lambda n=name: self._transfer_label.configure(
|
||||
text=t("uploading").format(name=n)
|
||||
@@ -441,10 +552,22 @@ class FilesTab(ctk.CTkFrame):
|
||||
self.after(0, lambda f=frac, s=size_str, nn=n:
|
||||
self._update_progress(f, t("uploading").format(name=nn) + f" ({s})"))
|
||||
|
||||
if self._sftp.sudo_mode:
|
||||
try:
|
||||
self._sftp.upload_sudo(local, remote, progress_cb=_progress)
|
||||
except Exception:
|
||||
self._sftp.upload(local, remote, progress_cb=_progress)
|
||||
else:
|
||||
self._sftp.upload(local, remote, progress_cb=_progress)
|
||||
|
||||
self.after(0, lambda n=name: self._log_msg(
|
||||
t("transfer_done").format(name=n)
|
||||
))
|
||||
except PermissionError as e:
|
||||
hint = f" - {t('try_sudo_hint')}" if not self._sftp.sudo_mode else ""
|
||||
self.after(0, lambda e=e, h=hint: self._log_msg(
|
||||
t("transfer_failed").format(e=str(e)) + h
|
||||
))
|
||||
except Exception as e:
|
||||
self.after(0, lambda e=e: self._log_msg(
|
||||
t("transfer_failed").format(e=str(e))
|
||||
@@ -454,12 +577,12 @@ class FilesTab(ctk.CTkFrame):
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _download_selected(self):
|
||||
def _do_download(self, items: list[dict]):
|
||||
"""Download files and folders from remote to local. Runs in background thread."""
|
||||
if not self._sftp or not self._sftp.connected or self._transferring:
|
||||
return
|
||||
selected = self._remote_list.get_selected()
|
||||
files = [s for s in selected if not s.get("is_dir") and s["name"] != ".."]
|
||||
if not files:
|
||||
items = [s for s in items if s["name"] != ".."]
|
||||
if not items:
|
||||
return
|
||||
|
||||
self._transferring = True
|
||||
@@ -467,14 +590,29 @@ class FilesTab(ctk.CTkFrame):
|
||||
self._download_btn.configure(state="disabled")
|
||||
|
||||
def _do():
|
||||
for item in files:
|
||||
for item in items:
|
||||
name = item["name"]
|
||||
if self._remote_path == "/":
|
||||
remote = "/" + name
|
||||
else:
|
||||
remote = self._remote_path + "/" + name
|
||||
remote = self._remote_join(name)
|
||||
local = os.path.join(self._local_path, name)
|
||||
try:
|
||||
if item.get("is_dir"):
|
||||
self.after(0, lambda n=name: self._transfer_label.configure(
|
||||
text=t("downloading_dir").format(name=n)
|
||||
))
|
||||
os.makedirs(local, exist_ok=True)
|
||||
|
||||
def _file_cb(cur, total, fname, n=name):
|
||||
self.after(0, lambda c=cur, tt=total, fn=fname:
|
||||
self._transfer_label.configure(
|
||||
text=t("transfer_file_progress").format(cur=c, total=tt, name=fn)
|
||||
))
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total > 0:
|
||||
self.after(0, lambda f=transferred/total: self._progress.set(f))
|
||||
|
||||
self._sftp.download_dir(remote, local, progress_cb=_progress, file_cb=_file_cb)
|
||||
else:
|
||||
self.after(0, lambda n=name: self._transfer_label.configure(
|
||||
text=t("downloading").format(name=n)
|
||||
))
|
||||
@@ -486,10 +624,22 @@ class FilesTab(ctk.CTkFrame):
|
||||
self.after(0, lambda f=frac, s=size_str, nn=n:
|
||||
self._update_progress(f, t("downloading").format(name=nn) + f" ({s})"))
|
||||
|
||||
if self._sftp.sudo_mode:
|
||||
try:
|
||||
self._sftp.download_sudo(remote, local, progress_cb=_progress)
|
||||
except Exception:
|
||||
self._sftp.download(remote, local, progress_cb=_progress)
|
||||
else:
|
||||
self._sftp.download(remote, local, progress_cb=_progress)
|
||||
|
||||
self.after(0, lambda n=name: self._log_msg(
|
||||
t("transfer_done").format(name=n)
|
||||
))
|
||||
except PermissionError as e:
|
||||
hint = f" - {t('try_sudo_hint')}" if not self._sftp.sudo_mode else ""
|
||||
self.after(0, lambda e=e, h=hint: self._log_msg(
|
||||
t("transfer_failed").format(e=str(e)) + h
|
||||
))
|
||||
except Exception as e:
|
||||
self.after(0, lambda e=e: self._log_msg(
|
||||
t("transfer_failed").format(e=str(e))
|
||||
@@ -499,6 +649,37 @@ class FilesTab(ctk.CTkFrame):
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
# ── Button handlers ──
|
||||
|
||||
def _upload_selected(self):
|
||||
if not self._sftp or not self._sftp.connected or self._transferring:
|
||||
return
|
||||
selected = self._local_list.get_selected()
|
||||
items = [s for s in selected if s["name"] != ".."]
|
||||
if not items:
|
||||
self._log_msg(t("no_server_selected"))
|
||||
return
|
||||
self._do_upload(items)
|
||||
|
||||
def _download_selected(self):
|
||||
if not self._sftp or not self._sftp.connected or self._transferring:
|
||||
return
|
||||
selected = self._remote_list.get_selected()
|
||||
items = [s for s in selected if s["name"] != ".."]
|
||||
if not items:
|
||||
return
|
||||
self._do_download(items)
|
||||
|
||||
# ── Drag-and-drop callbacks ──
|
||||
|
||||
def _on_drop_to_remote(self, items: list[dict], source):
|
||||
"""Called when items are dropped onto remote panel (upload)."""
|
||||
self._do_upload(items)
|
||||
|
||||
def _on_drop_to_local(self, items: list[dict], source):
|
||||
"""Called when items are dropped onto local panel (download)."""
|
||||
self._do_download(items)
|
||||
|
||||
def _on_transfer_done(self):
|
||||
self._transferring = False
|
||||
self._progress.set(0)
|
||||
@@ -522,10 +703,7 @@ class FilesTab(ctk.CTkFrame):
|
||||
if not name or not name.strip():
|
||||
return
|
||||
name = name.strip()
|
||||
if self._remote_path == "/":
|
||||
path = "/" + name
|
||||
else:
|
||||
path = self._remote_path + "/" + name
|
||||
path = self._remote_join(name)
|
||||
|
||||
def _do():
|
||||
try:
|
||||
@@ -543,6 +721,16 @@ class FilesTab(ctk.CTkFrame):
|
||||
items = [s for s in selected if s["name"] != ".."]
|
||||
if not items:
|
||||
return
|
||||
|
||||
# Check for directories — use recursive delete confirm
|
||||
has_dirs = any(s.get("is_dir") for s in items)
|
||||
if has_dirs and len(items) == 1 and items[0].get("is_dir"):
|
||||
if not messagebox.askyesno(
|
||||
t("delete_files"),
|
||||
t("recursive_delete_confirm").format(name=items[0]["name"]),
|
||||
):
|
||||
return
|
||||
else:
|
||||
if not messagebox.askyesno(
|
||||
t("delete_files"),
|
||||
t("delete_files_confirm").format(count=len(items)),
|
||||
@@ -552,13 +740,10 @@ class FilesTab(ctk.CTkFrame):
|
||||
def _do():
|
||||
for item in items:
|
||||
name = item["name"]
|
||||
if self._remote_path == "/":
|
||||
path = "/" + name
|
||||
else:
|
||||
path = self._remote_path + "/" + name
|
||||
path = self._remote_join(name)
|
||||
try:
|
||||
if item.get("is_dir"):
|
||||
self._sftp.rmdir(path)
|
||||
self._sftp.rmdir_recursive(path)
|
||||
else:
|
||||
self._sftp.remove(path)
|
||||
except Exception as e:
|
||||
@@ -583,12 +768,8 @@ class FilesTab(ctk.CTkFrame):
|
||||
if not new_name or not new_name.strip() or new_name.strip() == old_name:
|
||||
return
|
||||
new_name = new_name.strip()
|
||||
if self._remote_path == "/":
|
||||
old_path = "/" + old_name
|
||||
new_path = "/" + new_name
|
||||
else:
|
||||
old_path = self._remote_path + "/" + old_name
|
||||
new_path = self._remote_path + "/" + new_name
|
||||
old_path = self._remote_join(old_name)
|
||||
new_path = self._remote_join(new_name)
|
||||
|
||||
def _do():
|
||||
try:
|
||||
|
||||
@@ -49,17 +49,23 @@ def _apply_dark_theme():
|
||||
|
||||
|
||||
class FileListWidget(ctk.CTkFrame):
|
||||
"""File list with columns, sorting, multi-selection."""
|
||||
"""File list with columns, sorting, multi-selection, drag-and-drop."""
|
||||
|
||||
def __init__(self, master, columns: list[tuple[str, int]],
|
||||
on_navigate=None, on_select=None):
|
||||
on_navigate=None, on_select=None, on_drop=None):
|
||||
super().__init__(master, fg_color="#1e1e1e", corner_radius=6)
|
||||
|
||||
self._on_navigate = on_navigate
|
||||
self._on_select = on_select
|
||||
self._on_drop = on_drop
|
||||
self._items: list[dict] = []
|
||||
self._sort_col = None
|
||||
self._sort_reverse = False
|
||||
self._drag_partner: "FileListWidget | None" = None
|
||||
self._drag_start_x = 0
|
||||
self._drag_start_y = 0
|
||||
self._dragging = False
|
||||
self._orig_fg_color = "#1e1e1e"
|
||||
|
||||
_apply_dark_theme()
|
||||
|
||||
@@ -91,6 +97,11 @@ class FileListWidget(ctk.CTkFrame):
|
||||
self._tree.bind("<<TreeviewSelect>>", self._on_tree_select)
|
||||
self._tree.bind("<Return>", self._on_enter)
|
||||
|
||||
# Drag-and-drop bindings
|
||||
self._tree.bind("<ButtonPress-1>", self._on_drag_start, add="+")
|
||||
self._tree.bind("<B1-Motion>", self._on_drag_motion)
|
||||
self._tree.bind("<ButtonRelease-1>", self._on_drag_release, add="+")
|
||||
|
||||
def populate(self, items: list[dict]):
|
||||
"""Fill list with items. Each item: {name, size, date, perm, is_dir}."""
|
||||
self._items = items
|
||||
@@ -168,3 +179,46 @@ class FileListWidget(ctk.CTkFrame):
|
||||
|
||||
self._items = dirs + files
|
||||
self.populate(self._items)
|
||||
|
||||
# ── Drag-and-drop ──
|
||||
|
||||
def set_drag_partner(self, partner: "FileListWidget"):
|
||||
"""Link two panels for drag-and-drop."""
|
||||
self._drag_partner = partner
|
||||
|
||||
def _on_drag_start(self, event):
|
||||
self._drag_start_x = event.x_root
|
||||
self._drag_start_y = event.y_root
|
||||
self._dragging = False
|
||||
|
||||
def _on_drag_motion(self, event):
|
||||
if not self._drag_partner:
|
||||
return
|
||||
dx = abs(event.x_root - self._drag_start_x)
|
||||
dy = abs(event.y_root - self._drag_start_y)
|
||||
if not self._dragging and (dx > 10 or dy > 10):
|
||||
sel = self.get_selected()
|
||||
if sel and any(s["name"] != ".." for s in sel):
|
||||
self._dragging = True
|
||||
self._tree.configure(cursor="hand2")
|
||||
self._drag_partner.configure(fg_color="#1e3a5f")
|
||||
|
||||
def _on_drag_release(self, event):
|
||||
if not self._dragging or not self._drag_partner:
|
||||
self._dragging = False
|
||||
return
|
||||
self._dragging = False
|
||||
self._tree.configure(cursor="")
|
||||
self._drag_partner.configure(fg_color=self._orig_fg_color)
|
||||
|
||||
# Check if mouse is over partner widget
|
||||
px = self._drag_partner.winfo_rootx()
|
||||
py = self._drag_partner.winfo_rooty()
|
||||
pw = self._drag_partner.winfo_width()
|
||||
ph = self._drag_partner.winfo_height()
|
||||
mx, my = event.x_root, event.y_root
|
||||
|
||||
if px <= mx <= px + pw and py <= my <= py + ph:
|
||||
items = [s for s in self.get_selected() if s["name"] != ".."]
|
||||
if items and self._drag_partner._on_drop:
|
||||
self._drag_partner._on_drop(items, self)
|
||||
|
||||
BIN
releases/ServerManager-v1.8.0-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.8.0-win-x64.exe
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
"""Version info for ServerManager."""
|
||||
|
||||
__version__ = "1.7.0"
|
||||
__version__ = "1.8.0"
|
||||
__app_name__ = "ServerManager"
|
||||
__author__ = "aibot777"
|
||||
__description__ = "Desktop GUI for managing remote servers"
|
||||
|
||||
Reference in New Issue
Block a user