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",
"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 = {

View File

@@ -316,6 +316,7 @@ class SFTPSession:
self.key_path = key_path
self._client: paramiko.SSHClient | None = None
self._sftp: paramiko.SFTPClient | None = None
self.sudo_mode: bool = False
@property
def connected(self) -> bool:
@@ -380,6 +381,166 @@ class SFTPSession:
def normalize(self, path: str) -> str:
return self._sftp.normalize(path)
# ── Recursive operations ──
def upload_dir(self, local_dir: str, remote_dir: str, progress_cb=None, file_cb=None):
"""Recursively upload a local directory to remote."""
all_files = []
for root, dirs, files in os.walk(local_dir):
rel = os.path.relpath(root, local_dir)
remote_sub = remote_dir if rel == "." else remote_dir + "/" + rel.replace("\\", "/")
try:
self._sftp.mkdir(remote_sub)
except IOError:
pass
for f in files:
local_file = os.path.join(root, f)
remote_file = remote_sub + "/" + f
all_files.append((local_file, remote_file))
for idx, (local_file, remote_file) in enumerate(all_files):
if file_cb:
file_cb(idx + 1, len(all_files), os.path.basename(local_file))
self._sftp.put(local_file, remote_file, callback=progress_cb)
def download_dir(self, remote_dir: str, local_dir: str, progress_cb=None, file_cb=None):
"""Recursively download a remote directory to local."""
all_files = []
self._walk_remote(remote_dir, remote_dir, all_files)
for idx, (remote_file, rel_path) in enumerate(all_files):
local_file = os.path.join(local_dir, rel_path)
os.makedirs(os.path.dirname(local_file), exist_ok=True)
if file_cb:
file_cb(idx + 1, len(all_files), os.path.basename(remote_file))
self._sftp.get(remote_file, local_file, callback=progress_cb)
def _walk_remote(self, base: str, current: str, result: list):
"""Recursively walk remote directory, collecting (abs_path, relative_path) tuples."""
import stat as stat_mod
for attr in self._sftp.listdir_attr(current):
full = current + "/" + attr.filename
rel = full[len(base):].lstrip("/")
if stat_mod.S_ISDIR(attr.st_mode or 0):
self._walk_remote(base, full, result)
else:
result.append((full, rel.replace("/", os.sep)))
def rmdir_recursive(self, path: str):
"""Recursively delete a remote directory."""
import stat as stat_mod
for attr in self._sftp.listdir_attr(path):
child = path + "/" + attr.filename
if stat_mod.S_ISDIR(attr.st_mode or 0):
self.rmdir_recursive(child)
else:
self._sftp.remove(child)
self._sftp.rmdir(path)
# ── Sudo operations ──
def exec_command(self, cmd: str) -> str:
"""Execute command via SSH with optional sudo wrapper."""
if not self._client:
raise Exception("Not connected")
password = self.server.get("password", "")
user = self.server.get("user", "root")
if self.sudo_mode and user != "root":
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(cmd)}"
else:
full_cmd = cmd
stdin, stdout, stderr = self._client.exec_command(full_cmd, timeout=30)
if self.sudo_mode and user != "root" and password:
stdin.write(password + "\n")
stdin.flush()
return stdout.read().decode("utf-8", errors="replace")
def listdir_attr_sudo(self, path: str) -> list:
"""List directory using sudo ls -la, returning objects with .filename/.st_size/.st_mtime/.st_mode."""
output = self.exec_command(f"ls -la --time-style=+%s {_shell_quote(path)}")
results = []
for line in output.strip().splitlines():
if line.startswith("total "):
continue
parts = line.split(None, 7)
if len(parts) < 7:
continue
perms = parts[0]
if perms.startswith("d") or perms.startswith("l") or perms.startswith("-"):
pass
else:
continue
size_str = parts[4]
mtime_str = parts[5]
# parts[6] may be time or name depending on format
# With --time-style=+%s: perms links owner group size epoch name
name = parts[6] if len(parts) == 7 else parts[7]
if name in (".", ".."):
continue
try:
size = int(size_str)
except ValueError:
size = 0
try:
mtime = int(mtime_str)
except ValueError:
mtime = 0
mode = _parse_ls_perms(perms)
entry = _SudoFileAttr(name, size, mtime, mode)
results.append(entry)
return results
def upload_sudo(self, local_path: str, remote_path: str, progress_cb=None):
"""Upload via SFTP to /tmp then sudo mv to destination."""
import random
tmp_name = f"/tmp/.sm_upload_{random.randint(100000, 999999)}"
if progress_cb:
self._sftp.put(local_path, tmp_name, callback=progress_cb)
else:
self._sftp.put(local_path, tmp_name)
self.exec_command(f"mv {_shell_quote(tmp_name)} {_shell_quote(remote_path)}")
def download_sudo(self, remote_path: str, local_path: str, progress_cb=None):
"""Copy via sudo to /tmp then download via SFTP."""
import random
tmp_name = f"/tmp/.sm_download_{random.randint(100000, 999999)}"
self.exec_command(f"cp {_shell_quote(remote_path)} {_shell_quote(tmp_name)} && chmod 644 {_shell_quote(tmp_name)}")
try:
if progress_cb:
self._sftp.get(tmp_name, local_path, callback=progress_cb)
else:
self._sftp.get(tmp_name, local_path)
finally:
self.exec_command(f"rm -f {_shell_quote(tmp_name)}")
class _SudoFileAttr:
"""Mimics paramiko SFTPAttributes for sudo ls output."""
def __init__(self, filename: str, st_size: int, st_mtime: int, st_mode: int):
self.filename = filename
self.st_size = st_size
self.st_mtime = st_mtime
self.st_mode = st_mode
def _parse_ls_perms(perms: str) -> int:
"""Parse ls -l permission string like 'drwxr-xr-x' into stat mode int."""
import stat as stat_mod
mode = 0
if perms[0] == "d":
mode |= stat_mod.S_IFDIR
elif perms[0] == "l":
mode |= stat_mod.S_IFLNK
else:
mode |= stat_mod.S_IFREG
mapping = "rwxrwxrwx"
bits = [stat_mod.S_IRUSR, stat_mod.S_IWUSR, stat_mod.S_IXUSR,
stat_mod.S_IRGRP, stat_mod.S_IWGRP, stat_mod.S_IXGRP,
stat_mod.S_IROTH, stat_mod.S_IWOTH, stat_mod.S_IXOTH]
for i, (ch, bit) in enumerate(zip(perms[1:10], bits)):
if ch != "-":
mode |= bit
return mode
def _shell_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"

View File

@@ -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()

View File

@@ -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,7 +426,13 @@ class FilesTab(ctk.CTkFrame):
def _do():
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 = []
# Parent dir
if self._remote_path != "/":
@@ -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,99 +496,189 @@ class FilesTab(ctk.CTkFrame):
self._remote_path = path
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):
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:
items = [s for s in selected if s["name"] != ".."]
if not items:
self._log_msg(t("no_server_selected"))
return
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()
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()
files = [s for s in selected if not s.get("is_dir") and s["name"] != ".."]
if not files:
items = [s for s in selected if s["name"] != ".."]
if not items:
return
self._do_download(items)
self._transferring = True
self._upload_btn.configure(state="disabled")
self._download_btn.configure(state="disabled")
# ── Drag-and-drop callbacks ──
def _do():
for item in files:
name = item["name"]
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 _on_drop_to_remote(self, items: list[dict], source):
"""Called when items are dropped onto remote panel (upload)."""
self._do_upload(items)
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})"))
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_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
@@ -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,22 +721,29 @@ class FilesTab(ctk.CTkFrame):
items = [s for s in selected if s["name"] != ".."]
if not items:
return
if not messagebox.askyesno(
t("delete_files"),
t("delete_files_confirm").format(count=len(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)),
):
return
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:

View File

@@ -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)

Binary file not shown.

View File

@@ -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"