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

@@ -275,6 +275,15 @@ _EN = {
"parent_dir": "Parent directory", "parent_dir": "Parent directory",
"refresh_files": "Refresh", "refresh_files": "Refresh",
"items_count": "{count} items", "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 = { _RU = {
@@ -527,6 +536,15 @@ _RU = {
"parent_dir": "Родительская папка", "parent_dir": "Родительская папка",
"refresh_files": "Обновить", "refresh_files": "Обновить",
"items_count": "{count} элементов", "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 = { _ZH = {
@@ -779,6 +797,15 @@ _ZH = {
"parent_dir": "上级目录", "parent_dir": "上级目录",
"refresh_files": "刷新", "refresh_files": "刷新",
"items_count": "{count} 个项目", "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 = { _TRANSLATIONS = {

View File

@@ -316,6 +316,7 @@ class SFTPSession:
self.key_path = key_path self.key_path = key_path
self._client: paramiko.SSHClient | None = None self._client: paramiko.SSHClient | None = None
self._sftp: paramiko.SFTPClient | None = None self._sftp: paramiko.SFTPClient | None = None
self.sudo_mode: bool = False
@property @property
def connected(self) -> bool: def connected(self) -> bool:
@@ -380,6 +381,166 @@ class SFTPSession:
def normalize(self, path: str) -> str: def normalize(self, path: str) -> str:
return self._sftp.normalize(path) 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: def _shell_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'" return "'" + s.replace("'", "'\\''") + "'"

View File

@@ -171,8 +171,9 @@ class App(ctk.CTk):
# Use provided key or default to first tab # Use provided key or default to first tab
current_key = restore_tab_key or self._tab_keys[0] 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.terminal_tab._disconnect()
self.files_tab._disconnect_sftp()
# Detach tab contents # Detach tab contents
self.terminal_tab.pack_forget() self.terminal_tab.pack_forget()
@@ -227,5 +228,6 @@ class App(ctk.CTk):
def _on_close(self): def _on_close(self):
self.terminal_tab._disconnect() self.terminal_tab._disconnect()
self.files_tab._disconnect_sftp()
self.checker.stop() self.checker.stop()
self.destroy() self.destroy()

View File

@@ -3,10 +3,12 @@ Files tab — dual-pane SFTP file manager.
""" """
import os import os
import platform
import stat import stat
import string
import threading import threading
from datetime import datetime from datetime import datetime
from tkinter import messagebox from tkinter import messagebox, filedialog
import customtkinter as ctk import customtkinter as ctk
@@ -45,6 +47,16 @@ def _format_perm(mode: int) -> str:
return "".join(parts) 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): class FilesTab(ctk.CTkFrame):
def __init__(self, master, store): def __init__(self, master, store):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent")
@@ -56,6 +68,7 @@ class FilesTab(ctk.CTkFrame):
self._local_history: list[str] = [] self._local_history: list[str] = []
self._remote_history: list[str] = [] self._remote_history: list[str] = []
self._transferring = False self._transferring = False
self._sudo_var = ctk.BooleanVar(value=False)
self._build_ui() self._build_ui()
self._refresh_local() self._refresh_local()
@@ -87,6 +100,33 @@ class FilesTab(ctk.CTkFrame):
) )
self._local_up_btn.pack(side="left", padx=2) 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 = ctk.CTkEntry(left_header, height=28)
self._local_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0)) 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()) self._local_path_entry.bind("<Return>", lambda e: self._local_go_to_path())
@@ -95,6 +135,7 @@ class FilesTab(ctk.CTkFrame):
left_pane, left_pane,
columns=[(t("name_col"), 220), (t("size_col"), 80), (t("date_col"), 110)], columns=[(t("name_col"), 220), (t("size_col"), 80), (t("date_col"), 110)],
on_navigate=self._navigate_local, on_navigate=self._navigate_local,
on_drop=self._on_drop_to_local,
) )
self._local_list.pack(fill="both", expand=True) self._local_list.pack(fill="both", expand=True)
@@ -143,6 +184,7 @@ class FilesTab(ctk.CTkFrame):
(t("date_col"), 100), (t("perm_col"), 80), (t("date_col"), 100), (t("perm_col"), 80),
], ],
on_navigate=self._navigate_remote, on_navigate=self._navigate_remote,
on_drop=self._on_drop_to_remote,
) )
self._remote_list.pack(fill="both", expand=True) 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)) 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 ===
toolbar = ctk.CTkFrame(self, fg_color="transparent") toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=10, pady=4) toolbar.pack(fill="x", padx=10, pady=4)
@@ -190,6 +236,16 @@ class FilesTab(ctk.CTkFrame):
) )
self._rename_btn.pack(side="left", padx=4) 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") self._set_remote_buttons_state("disabled")
# === Transfer / Log area === # === Transfer / Log area ===
@@ -248,6 +304,7 @@ class FilesTab(ctk.CTkFrame):
def _on_sftp_connected(self, sftp: SFTPSession, home: str): def _on_sftp_connected(self, sftp: SFTPSession, home: str):
self._sftp = sftp self._sftp = sftp
self._sftp.sudo_mode = self._sudo_var.get()
self._remote_path = home self._remote_path = home
self._remote_history.clear() self._remote_history.clear()
self._remote_status.configure( self._remote_status.configure(
@@ -268,11 +325,36 @@ class FilesTab(ctk.CTkFrame):
pass pass
self._sftp = None 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 ── # ── Local navigation ──
def _refresh_local(self): def _refresh_local(self):
self._local_path_entry.delete(0, "end") self._local_path_entry.delete(0, "end")
self._local_path_entry.insert(0, self._local_path) 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: try:
entries = os.listdir(self._local_path) entries = os.listdir(self._local_path)
except PermissionError: except PermissionError:
@@ -344,6 +426,12 @@ class FilesTab(ctk.CTkFrame):
def _do(): def _do():
try: 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) attrs = self._sftp.listdir_attr(self._remote_path)
items = [] items = []
# Parent dir # Parent dir
@@ -361,6 +449,9 @@ class FilesTab(ctk.CTkFrame):
"is_dir": is_dir, "is_dir": is_dir,
}) })
self.after(0, lambda: self._populate_remote(items)) 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: except Exception as e:
self.after(0, lambda: self._on_sftp_error(str(e))) self.after(0, lambda: self._on_sftp_error(str(e)))
@@ -405,15 +496,21 @@ class FilesTab(ctk.CTkFrame):
self._remote_path = path self._remote_path = path
self._refresh_remote() 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: if not self._sftp or not self._sftp.connected or self._transferring:
return return
selected = self._local_list.get_selected() items = [s for s in items if s["name"] != ".."]
files = [s for s in selected if not s.get("is_dir") and s["name"] != ".."] if not items:
if not files:
self._log_msg(t("no_server_selected"))
return return
self._transferring = True self._transferring = True
@@ -421,14 +518,28 @@ class FilesTab(ctk.CTkFrame):
self._download_btn.configure(state="disabled") self._download_btn.configure(state="disabled")
def _do(): def _do():
for item in files: for item in items:
name = item["name"] name = item["name"]
local = os.path.join(self._local_path, name) local = os.path.join(self._local_path, name)
if self._remote_path == "/": remote = self._remote_join(name)
remote = "/" + name
else:
remote = self._remote_path + "/" + name
try: 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) file_size = os.path.getsize(local)
self.after(0, lambda n=name: self._transfer_label.configure( self.after(0, lambda n=name: self._transfer_label.configure(
text=t("uploading").format(name=n) 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.after(0, lambda f=frac, s=size_str, nn=n:
self._update_progress(f, t("uploading").format(name=nn) + f" ({s})")) 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) 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( self.after(0, lambda n=name: self._log_msg(
t("transfer_done").format(name=n) 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: except Exception as e:
self.after(0, lambda e=e: self._log_msg( self.after(0, lambda e=e: self._log_msg(
t("transfer_failed").format(e=str(e)) t("transfer_failed").format(e=str(e))
@@ -454,12 +577,12 @@ class FilesTab(ctk.CTkFrame):
threading.Thread(target=_do, daemon=True).start() 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: if not self._sftp or not self._sftp.connected or self._transferring:
return return
selected = self._remote_list.get_selected() items = [s for s in items if s["name"] != ".."]
files = [s for s in selected if not s.get("is_dir") and s["name"] != ".."] if not items:
if not files:
return return
self._transferring = True self._transferring = True
@@ -467,14 +590,29 @@ class FilesTab(ctk.CTkFrame):
self._download_btn.configure(state="disabled") self._download_btn.configure(state="disabled")
def _do(): def _do():
for item in files: for item in items:
name = item["name"] name = item["name"]
if self._remote_path == "/": remote = self._remote_join(name)
remote = "/" + name
else:
remote = self._remote_path + "/" + name
local = os.path.join(self._local_path, name) local = os.path.join(self._local_path, name)
try: 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( self.after(0, lambda n=name: self._transfer_label.configure(
text=t("downloading").format(name=n) 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.after(0, lambda f=frac, s=size_str, nn=n:
self._update_progress(f, t("downloading").format(name=nn) + f" ({s})")) 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) 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( self.after(0, lambda n=name: self._log_msg(
t("transfer_done").format(name=n) 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: except Exception as e:
self.after(0, lambda e=e: self._log_msg( self.after(0, lambda e=e: self._log_msg(
t("transfer_failed").format(e=str(e)) t("transfer_failed").format(e=str(e))
@@ -499,6 +649,37 @@ class FilesTab(ctk.CTkFrame):
threading.Thread(target=_do, daemon=True).start() 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): def _on_transfer_done(self):
self._transferring = False self._transferring = False
self._progress.set(0) self._progress.set(0)
@@ -522,10 +703,7 @@ class FilesTab(ctk.CTkFrame):
if not name or not name.strip(): if not name or not name.strip():
return return
name = name.strip() name = name.strip()
if self._remote_path == "/": path = self._remote_join(name)
path = "/" + name
else:
path = self._remote_path + "/" + name
def _do(): def _do():
try: try:
@@ -543,6 +721,16 @@ class FilesTab(ctk.CTkFrame):
items = [s for s in selected if s["name"] != ".."] items = [s for s in selected if s["name"] != ".."]
if not items: if not items:
return 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( if not messagebox.askyesno(
t("delete_files"), t("delete_files"),
t("delete_files_confirm").format(count=len(items)), t("delete_files_confirm").format(count=len(items)),
@@ -552,13 +740,10 @@ class FilesTab(ctk.CTkFrame):
def _do(): def _do():
for item in items: for item in items:
name = item["name"] name = item["name"]
if self._remote_path == "/": path = self._remote_join(name)
path = "/" + name
else:
path = self._remote_path + "/" + name
try: try:
if item.get("is_dir"): if item.get("is_dir"):
self._sftp.rmdir(path) self._sftp.rmdir_recursive(path)
else: else:
self._sftp.remove(path) self._sftp.remove(path)
except Exception as e: 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: if not new_name or not new_name.strip() or new_name.strip() == old_name:
return return
new_name = new_name.strip() new_name = new_name.strip()
if self._remote_path == "/": old_path = self._remote_join(old_name)
old_path = "/" + old_name new_path = self._remote_join(new_name)
new_path = "/" + new_name
else:
old_path = self._remote_path + "/" + old_name
new_path = self._remote_path + "/" + new_name
def _do(): def _do():
try: try:

View File

@@ -49,17 +49,23 @@ def _apply_dark_theme():
class FileListWidget(ctk.CTkFrame): 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]], 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) super().__init__(master, fg_color="#1e1e1e", corner_radius=6)
self._on_navigate = on_navigate self._on_navigate = on_navigate
self._on_select = on_select self._on_select = on_select
self._on_drop = on_drop
self._items: list[dict] = [] self._items: list[dict] = []
self._sort_col = None self._sort_col = None
self._sort_reverse = False 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() _apply_dark_theme()
@@ -91,6 +97,11 @@ class FileListWidget(ctk.CTkFrame):
self._tree.bind("<<TreeviewSelect>>", self._on_tree_select) self._tree.bind("<<TreeviewSelect>>", self._on_tree_select)
self._tree.bind("<Return>", self._on_enter) 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]): def populate(self, items: list[dict]):
"""Fill list with items. Each item: {name, size, date, perm, is_dir}.""" """Fill list with items. Each item: {name, size, date, perm, is_dir}."""
self._items = items self._items = items
@@ -168,3 +179,46 @@ class FileListWidget(ctk.CTkFrame):
self._items = dirs + files self._items = dirs + files
self.populate(self._items) 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)

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager.""" """Version info for ServerManager."""
__version__ = "1.7.0" __version__ = "1.8.0"
__app_name__ = "ServerManager" __app_name__ = "ServerManager"
__author__ = "aibot777" __author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers" __description__ = "Desktop GUI for managing remote servers"