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",
|
"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 = {
|
||||||
|
|||||||
@@ -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("'", "'\\''") + "'"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,7 +426,13 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
|
|
||||||
def _do():
|
def _do():
|
||||||
try:
|
try:
|
||||||
attrs = self._sftp.listdir_attr(self._remote_path)
|
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 = []
|
items = []
|
||||||
# Parent dir
|
# Parent dir
|
||||||
if self._remote_path != "/":
|
if self._remote_path != "/":
|
||||||
@@ -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,99 +496,189 @@ class FilesTab(ctk.CTkFrame):
|
|||||||
self._remote_path = path
|
self._remote_path = path
|
||||||
self._refresh_remote()
|
self._refresh_remote()
|
||||||
|
|
||||||
# ── File operations ──
|
# ── Remote path helper ──
|
||||||
|
|
||||||
|
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
|
||||||
|
items = [s for s in items if s["name"] != ".."]
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._transferring = True
|
||||||
|
self._upload_btn.configure(state="disabled")
|
||||||
|
self._download_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
for item in items:
|
||||||
|
name = item["name"]
|
||||||
|
local = os.path.join(self._local_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)
|
||||||
|
))
|
||||||
|
|
||||||
|
def _progress(transferred, total, n=name):
|
||||||
|
if total > 0:
|
||||||
|
frac = transferred / total
|
||||||
|
size_str = f"{_format_size(transferred)}/{_format_size(total)}"
|
||||||
|
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))
|
||||||
|
))
|
||||||
|
|
||||||
|
self.after(0, self._on_transfer_done)
|
||||||
|
|
||||||
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
|
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
|
||||||
|
items = [s for s in items if s["name"] != ".."]
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._transferring = True
|
||||||
|
self._upload_btn.configure(state="disabled")
|
||||||
|
self._download_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
for item in items:
|
||||||
|
name = item["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)
|
||||||
|
))
|
||||||
|
|
||||||
|
def _progress(transferred, total, n=name):
|
||||||
|
if total > 0:
|
||||||
|
frac = transferred / total
|
||||||
|
size_str = f"{_format_size(transferred)}/{_format_size(total)}"
|
||||||
|
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))
|
||||||
|
))
|
||||||
|
|
||||||
|
self.after(0, self._on_transfer_done)
|
||||||
|
|
||||||
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
|
# ── Button handlers ──
|
||||||
|
|
||||||
def _upload_selected(self):
|
def _upload_selected(self):
|
||||||
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()
|
selected = self._local_list.get_selected()
|
||||||
files = [s for s in selected if not s.get("is_dir") and s["name"] != ".."]
|
items = [s for s in selected if s["name"] != ".."]
|
||||||
if not files:
|
if not items:
|
||||||
self._log_msg(t("no_server_selected"))
|
self._log_msg(t("no_server_selected"))
|
||||||
return
|
return
|
||||||
|
self._do_upload(items)
|
||||||
self._transferring = True
|
|
||||||
self._upload_btn.configure(state="disabled")
|
|
||||||
self._download_btn.configure(state="disabled")
|
|
||||||
|
|
||||||
def _do():
|
|
||||||
for item in files:
|
|
||||||
name = item["name"]
|
|
||||||
local = os.path.join(self._local_path, name)
|
|
||||||
if self._remote_path == "/":
|
|
||||||
remote = "/" + name
|
|
||||||
else:
|
|
||||||
remote = self._remote_path + "/" + name
|
|
||||||
try:
|
|
||||||
file_size = os.path.getsize(local)
|
|
||||||
self.after(0, lambda n=name: self._transfer_label.configure(
|
|
||||||
text=t("uploading").format(name=n)
|
|
||||||
))
|
|
||||||
|
|
||||||
def _progress(transferred, total, n=name):
|
|
||||||
if total > 0:
|
|
||||||
frac = transferred / total
|
|
||||||
size_str = f"{_format_size(transferred)}/{_format_size(total)}"
|
|
||||||
self.after(0, lambda f=frac, s=size_str, nn=n:
|
|
||||||
self._update_progress(f, t("uploading").format(name=nn) + f" ({s})"))
|
|
||||||
|
|
||||||
self._sftp.upload(local, remote, progress_cb=_progress)
|
|
||||||
self.after(0, lambda n=name: self._log_msg(
|
|
||||||
t("transfer_done").format(name=n)
|
|
||||||
))
|
|
||||||
except Exception as e:
|
|
||||||
self.after(0, lambda e=e: self._log_msg(
|
|
||||||
t("transfer_failed").format(e=str(e))
|
|
||||||
))
|
|
||||||
|
|
||||||
self.after(0, self._on_transfer_done)
|
|
||||||
|
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
|
||||||
|
|
||||||
def _download_selected(self):
|
def _download_selected(self):
|
||||||
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()
|
selected = self._remote_list.get_selected()
|
||||||
files = [s for s in selected if not s.get("is_dir") and s["name"] != ".."]
|
items = [s for s in selected if s["name"] != ".."]
|
||||||
if not files:
|
if not items:
|
||||||
return
|
return
|
||||||
|
self._do_download(items)
|
||||||
|
|
||||||
self._transferring = True
|
# ── Drag-and-drop callbacks ──
|
||||||
self._upload_btn.configure(state="disabled")
|
|
||||||
self._download_btn.configure(state="disabled")
|
|
||||||
|
|
||||||
def _do():
|
def _on_drop_to_remote(self, items: list[dict], source):
|
||||||
for item in files:
|
"""Called when items are dropped onto remote panel (upload)."""
|
||||||
name = item["name"]
|
self._do_upload(items)
|
||||||
if self._remote_path == "/":
|
|
||||||
remote = "/" + name
|
|
||||||
else:
|
|
||||||
remote = self._remote_path + "/" + name
|
|
||||||
local = os.path.join(self._local_path, name)
|
|
||||||
try:
|
|
||||||
self.after(0, lambda n=name: self._transfer_label.configure(
|
|
||||||
text=t("downloading").format(name=n)
|
|
||||||
))
|
|
||||||
|
|
||||||
def _progress(transferred, total, n=name):
|
def _on_drop_to_local(self, items: list[dict], source):
|
||||||
if total > 0:
|
"""Called when items are dropped onto local panel (download)."""
|
||||||
frac = transferred / total
|
self._do_download(items)
|
||||||
size_str = f"{_format_size(transferred)}/{_format_size(total)}"
|
|
||||||
self.after(0, lambda f=frac, s=size_str, nn=n:
|
|
||||||
self._update_progress(f, t("downloading").format(name=nn) + f" ({s})"))
|
|
||||||
|
|
||||||
self._sftp.download(remote, local, progress_cb=_progress)
|
|
||||||
self.after(0, lambda n=name: self._log_msg(
|
|
||||||
t("transfer_done").format(name=n)
|
|
||||||
))
|
|
||||||
except Exception as e:
|
|
||||||
self.after(0, lambda e=e: self._log_msg(
|
|
||||||
t("transfer_failed").format(e=str(e))
|
|
||||||
))
|
|
||||||
|
|
||||||
self.after(0, self._on_transfer_done)
|
|
||||||
|
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
|
||||||
|
|
||||||
def _on_transfer_done(self):
|
def _on_transfer_done(self):
|
||||||
self._transferring = False
|
self._transferring = False
|
||||||
@@ -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,22 +721,29 @@ 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
|
||||||
if not messagebox.askyesno(
|
|
||||||
t("delete_files"),
|
# Check for directories — use recursive delete confirm
|
||||||
t("delete_files_confirm").format(count=len(items)),
|
has_dirs = any(s.get("is_dir") for s in items)
|
||||||
):
|
if has_dirs and len(items) == 1 and items[0].get("is_dir"):
|
||||||
return
|
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)),
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user