v1.7.0: dual-pane SFTP file manager

Replace primitive upload/download form with a full dual-pane file manager
(local + remote) featuring directory browsing, navigation history,
file operations (upload, download, mkdir, delete, rename), progress
tracking, and persistent SFTP sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 16:09:55 -05:00
parent c95ce8119b
commit a77ca6fee7
6 changed files with 912 additions and 139 deletions

View File

@@ -247,6 +247,34 @@ _EN = {
# Network interface # Network interface
"network_interface": "Network Interface", "network_interface": "Network Interface",
"auto_default": "Auto (default)", "auto_default": "Auto (default)",
# File browser
"file_browser": "File Browser",
"local_files": "Local",
"remote_files": "Remote",
"connect_to_browse": "Select a server to browse files",
"connecting_sftp": "Connecting...",
"connected_sftp": "Connected to {alias}",
"disconnected_sftp": "Disconnected",
"sftp_error": "SFTP error: {e}",
"uploading": "Uploading: {name}",
"downloading": "Downloading: {name}",
"transfer_done": "Transfer complete: {name}",
"transfer_failed": "Transfer failed: {e}",
"new_folder": "New Folder",
"new_folder_name": "Folder name:",
"delete_files": "Delete",
"delete_files_confirm": "Delete {count} item(s)?",
"rename_file": "Rename",
"rename_prompt": "New name:",
"permission_denied": "Permission denied: {path}",
"name_col": "Name",
"size_col": "Size",
"date_col": "Date",
"perm_col": "Perm",
"parent_dir": "Parent directory",
"refresh_files": "Refresh",
"items_count": "{count} items",
} }
_RU = { _RU = {
@@ -471,6 +499,34 @@ _RU = {
# Network interface # Network interface
"network_interface": "Сетевой интерфейс", "network_interface": "Сетевой интерфейс",
"auto_default": "Авто (по умолчанию)", "auto_default": "Авто (по умолчанию)",
# File browser
"file_browser": "Файл-менеджер",
"local_files": "Локальные",
"remote_files": "Удалённые",
"connect_to_browse": "Выберите сервер для просмотра файлов",
"connecting_sftp": "Подключение...",
"connected_sftp": "Подключено к {alias}",
"disconnected_sftp": "Отключено",
"sftp_error": "Ошибка SFTP: {e}",
"uploading": "Загрузка: {name}",
"downloading": "Скачивание: {name}",
"transfer_done": "Передача завершена: {name}",
"transfer_failed": "Ошибка передачи: {e}",
"new_folder": "Новая папка",
"new_folder_name": "Имя папки:",
"delete_files": "Удалить",
"delete_files_confirm": "Удалить {count} элемент(ов)?",
"rename_file": "Переименовать",
"rename_prompt": "Новое имя:",
"permission_denied": "Нет доступа: {path}",
"name_col": "Имя",
"size_col": "Размер",
"date_col": "Дата",
"perm_col": "Права",
"parent_dir": "Родительская папка",
"refresh_files": "Обновить",
"items_count": "{count} элементов",
} }
_ZH = { _ZH = {
@@ -695,6 +751,34 @@ _ZH = {
# Network interface # Network interface
"network_interface": "网络接口", "network_interface": "网络接口",
"auto_default": "自动(默认)", "auto_default": "自动(默认)",
# File browser
"file_browser": "文件管理器",
"local_files": "本地",
"remote_files": "远程",
"connect_to_browse": "选择服务器以浏览文件",
"connecting_sftp": "连接中...",
"connected_sftp": "已连接到 {alias}",
"disconnected_sftp": "已断开",
"sftp_error": "SFTP错误{e}",
"uploading": "上传中:{name}",
"downloading": "下载中:{name}",
"transfer_done": "传输完成:{name}",
"transfer_failed": "传输失败:{e}",
"new_folder": "新建文件夹",
"new_folder_name": "文件夹名称:",
"delete_files": "删除",
"delete_files_confirm": "删除 {count} 个项目?",
"rename_file": "重命名",
"rename_prompt": "新名称:",
"permission_denied": "权限被拒绝:{path}",
"name_col": "名称",
"size_col": "大小",
"date_col": "日期",
"perm_col": "权限",
"parent_dir": "上级目录",
"refresh_files": "刷新",
"items_count": "{count} 个项目",
} }
_TRANSLATIONS = { _TRANSLATIONS = {

View File

@@ -308,5 +308,78 @@ class SSHClientWrapper:
return f"Key generated: {self.key_path}" return f"Key generated: {self.key_path}"
class SFTPSession:
"""Persistent SFTP session for file browser."""
def __init__(self, server: dict, key_path: str):
self.server = server
self.key_path = key_path
self._client: paramiko.SSHClient | None = None
self._sftp: paramiko.SFTPClient | None = None
@property
def connected(self) -> bool:
try:
return (
self._client is not None
and self._sftp is not None
and self._client.get_transport() is not None
and self._client.get_transport().is_active()
)
except Exception:
return False
def connect(self):
self._client = _connect_client(self.server, self.key_path)
self._sftp = self._client.open_sftp()
def disconnect(self):
if self._sftp:
try:
self._sftp.close()
except Exception:
pass
self._sftp = None
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = None
def listdir_attr(self, path: str) -> list:
return self._sftp.listdir_attr(path)
def stat(self, path: str):
return self._sftp.stat(path)
def mkdir(self, path: str):
self._sftp.mkdir(path)
def rmdir(self, path: str):
self._sftp.rmdir(path)
def remove(self, path: str):
self._sftp.remove(path)
def rename(self, old: str, new: str):
self._sftp.rename(old, new)
def upload(self, local_path: str, remote_path: str, progress_cb=None):
if progress_cb:
self._sftp.put(local_path, remote_path, callback=progress_cb)
else:
self._sftp.put(local_path, remote_path)
def download(self, remote_path: str, local_path: str, progress_cb=None):
if progress_cb:
self._sftp.get(remote_path, local_path, callback=progress_cb)
else:
self._sftp.get(remote_path, local_path)
def normalize(self, path: str) -> str:
return self._sftp.normalize(path)
def _shell_quote(s: str) -> str: def _shell_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'" return "'" + s.replace("'", "'\\''") + "'"

View File

@@ -1,13 +1,48 @@
""" """
Files tab — SFTP upload/download. Files tab — dual-pane SFTP file manager.
""" """
import os import os
import stat
import threading import threading
from datetime import datetime
from tkinter import messagebox
import customtkinter as ctk import customtkinter as ctk
from tkinter import filedialog
from core.ssh_client import SSHClientWrapper
from core.i18n import t from core.i18n import t
from core.ssh_client import SFTPSession
from gui.widgets.file_list import FileListWidget
def _format_size(size_bytes: int) -> str:
if size_bytes < 0:
return ""
for unit in ("B", "KB", "MB", "GB"):
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}" if unit != "B" else f"{size_bytes} B"
size_bytes /= 1024
return f"{size_bytes:.1f} TB"
def _format_date(mtime: float) -> str:
try:
dt = datetime.fromtimestamp(mtime)
return dt.strftime("%b %d %H:%M")
except Exception:
return ""
def _format_perm(mode: int) -> str:
parts = []
for who in (6, 3, 0):
m = (mode >> who) & 7
parts.append(
("r" if m & 4 else "-")
+ ("w" if m & 2 else "-")
+ ("x" if m & 1 else "-")
)
return "".join(parts)
class FilesTab(ctk.CTkFrame): class FilesTab(ctk.CTkFrame):
@@ -15,156 +50,567 @@ class FilesTab(ctk.CTkFrame):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent")
self.store = store self.store = store
self._current_alias: str | None = None self._current_alias: str | None = None
self._sftp: SFTPSession | None = None
self._local_path = os.path.expanduser("~")
self._remote_path = "/"
self._local_history: list[str] = []
self._remote_history: list[str] = []
self._transferring = False
# Upload section self._build_ui()
self.upload_label = ctk.CTkLabel(self, text=t("upload"), font=ctk.CTkFont(size=14, weight="bold"), anchor="w") self._refresh_local()
self.upload_label.pack(fill="x", padx=15, pady=(15, 5))
upload_frame = ctk.CTkFrame(self, fg_color="transparent") def _build_ui(self):
upload_frame.pack(fill="x", padx=15, pady=(0, 5)) # === Panes area ===
panes = ctk.CTkFrame(self, fg_color="transparent")
panes.pack(fill="both", expand=True, padx=10, pady=(10, 5))
self.upload_local_label = ctk.CTkLabel(upload_frame, text=t("local"), width=60, anchor="w") # Left pane — Local
self.upload_local_label.pack(side="left") left_pane = ctk.CTkFrame(panes, fg_color="transparent")
self.upload_local = ctk.CTkEntry(upload_frame, placeholder_text=t("placeholder_local_file")) left_pane.pack(side="left", fill="both", expand=True, padx=(0, 5))
self.upload_local.pack(side="left", fill="x", expand=True, padx=5)
self.browse_upload_btn = ctk.CTkButton(upload_frame, text=t("browse"), width=70, command=self._browse_upload)
self.browse_upload_btn.pack(side="right")
upload_remote_frame = ctk.CTkFrame(self, fg_color="transparent") left_header = ctk.CTkFrame(left_pane, fg_color="transparent")
upload_remote_frame.pack(fill="x", padx=15, pady=(0, 5)) left_header.pack(fill="x", pady=(0, 4))
self.upload_remote_label = ctk.CTkLabel(upload_remote_frame, text=t("remote"), width=60, anchor="w") ctk.CTkLabel(left_header, text=t("local_files"),
self.upload_remote_label.pack(side="left") font=ctk.CTkFont(size=13, weight="bold")).pack(side="left")
self.upload_remote = ctk.CTkEntry(upload_remote_frame, placeholder_text=t("placeholder_remote_file"))
self.upload_remote.pack(side="left", fill="x", expand=True, padx=5)
self.upload_btn = ctk.CTkButton(upload_remote_frame, text=t("upload"), width=70, command=self._upload)
self.upload_btn.pack(side="right")
# Separator self._local_back_btn = ctk.CTkButton(
ctk.CTkFrame(self, height=2, fg_color="gray40").pack(fill="x", padx=15, pady=10) left_header, text="\u2190", width=30, height=28,
command=self._local_go_back,
)
self._local_back_btn.pack(side="left", padx=(8, 2))
# Download section self._local_up_btn = ctk.CTkButton(
self.download_label = ctk.CTkLabel(self, text=t("download"), font=ctk.CTkFont(size=14, weight="bold"), anchor="w") left_header, text="\u2191", width=30, height=28,
self.download_label.pack(fill="x", padx=15, pady=(5, 5)) command=self._local_go_up,
)
self._local_up_btn.pack(side="left", padx=2)
download_remote_frame = ctk.CTkFrame(self, fg_color="transparent") self._local_path_entry = ctk.CTkEntry(left_header, height=28)
download_remote_frame.pack(fill="x", padx=15, pady=(0, 5)) 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.download_remote_label = ctk.CTkLabel(download_remote_frame, text=t("remote"), width=60, anchor="w") self._local_list = FileListWidget(
self.download_remote_label.pack(side="left") left_pane,
self.download_remote = ctk.CTkEntry(download_remote_frame, placeholder_text=t("placeholder_remote_file")) columns=[(t("name_col"), 220), (t("size_col"), 80), (t("date_col"), 110)],
self.download_remote.pack(side="left", fill="x", expand=True, padx=5) on_navigate=self._navigate_local,
)
self._local_list.pack(fill="both", expand=True)
download_local_frame = ctk.CTkFrame(self, fg_color="transparent") self._local_status = ctk.CTkLabel(
download_local_frame.pack(fill="x", padx=15, pady=(0, 5)) left_pane, text="", font=ctk.CTkFont(size=11),
text_color="#9ca3af", anchor="w",
)
self._local_status.pack(fill="x", pady=(2, 0))
self.download_local_label = ctk.CTkLabel(download_local_frame, text=t("local"), width=60, anchor="w") # Right pane — Remote
self.download_local_label.pack(side="left") right_pane = ctk.CTkFrame(panes, fg_color="transparent")
self.download_local = ctk.CTkEntry(download_local_frame, placeholder_text=t("placeholder_save_path")) right_pane.pack(side="right", fill="both", expand=True, padx=(5, 0))
self.download_local.pack(side="left", fill="x", expand=True, padx=5)
self.browse_download_btn = ctk.CTkButton(download_local_frame, text=t("browse"), width=70, command=self._browse_download)
self.browse_download_btn.pack(side="left", padx=(5, 0))
self.download_btn = ctk.CTkButton(download_local_frame, text=t("download"), width=80, command=self._download)
self.download_btn.pack(side="right")
# Progress right_header = ctk.CTkFrame(right_pane, fg_color="transparent")
self.progress = ctk.CTkProgressBar(self) right_header.pack(fill="x", pady=(0, 4))
self.progress.pack(fill="x", padx=15, pady=(10, 5))
self.progress.set(0)
# Log ctk.CTkLabel(right_header, text=t("remote_files"),
self.log = ctk.CTkTextbox(self, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled") font=ctk.CTkFont(size=13, weight="bold")).pack(side="left")
self.log.pack(fill="both", expand=True, padx=15, pady=(5, 15))
self._remote_back_btn = ctk.CTkButton(
right_header, text="\u2190", width=30, height=28,
command=self._remote_go_back,
)
self._remote_back_btn.pack(side="left", padx=(8, 2))
self._remote_up_btn = ctk.CTkButton(
right_header, text="\u2191", width=30, height=28,
command=self._remote_go_up,
)
self._remote_up_btn.pack(side="left", padx=2)
self._remote_refresh_btn = ctk.CTkButton(
right_header, text="\u21BB", width=30, height=28,
command=self._refresh_remote,
)
self._remote_refresh_btn.pack(side="left", padx=2)
self._remote_path_entry = ctk.CTkEntry(right_header, height=28)
self._remote_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0))
self._remote_path_entry.bind("<Return>", lambda e: self._remote_go_to_path())
self._remote_list = FileListWidget(
right_pane,
columns=[
(t("name_col"), 200), (t("size_col"), 70),
(t("date_col"), 100), (t("perm_col"), 80),
],
on_navigate=self._navigate_remote,
)
self._remote_list.pack(fill="both", expand=True)
self._remote_status = ctk.CTkLabel(
right_pane, text=t("connect_to_browse"),
font=ctk.CTkFont(size=11), text_color="#9ca3af", anchor="w",
)
self._remote_status.pack(fill="x", pady=(2, 0))
# === Toolbar ===
toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=10, pady=4)
self._upload_btn = ctk.CTkButton(
toolbar, text=f"{t('upload')} \u2192", width=110, height=30,
command=self._upload_selected,
)
self._upload_btn.pack(side="left", padx=(0, 4))
self._download_btn = ctk.CTkButton(
toolbar, text=f"\u2190 {t('download')}", width=110, height=30,
command=self._download_selected,
)
self._download_btn.pack(side="left", padx=4)
sep = ctk.CTkFrame(toolbar, width=2, height=24, fg_color="gray40")
sep.pack(side="left", padx=8)
self._mkdir_btn = ctk.CTkButton(
toolbar, text=t("new_folder"), width=100, height=30,
command=self._mkdir_remote,
)
self._mkdir_btn.pack(side="left", padx=4)
self._delete_btn = ctk.CTkButton(
toolbar, text=t("delete_files"), width=80, height=30,
fg_color="#dc2626", hover_color="#b91c1c",
command=self._delete_remote,
)
self._delete_btn.pack(side="left", padx=4)
self._rename_btn = ctk.CTkButton(
toolbar, text=t("rename_file"), width=100, height=30,
command=self._rename_remote,
)
self._rename_btn.pack(side="left", padx=4)
self._set_remote_buttons_state("disabled")
# === Transfer / Log area ===
transfer_frame = ctk.CTkFrame(self, fg_color="transparent")
transfer_frame.pack(fill="x", padx=10, pady=(2, 2))
self._progress = ctk.CTkProgressBar(transfer_frame, height=14)
self._progress.pack(fill="x")
self._progress.set(0)
self._transfer_label = ctk.CTkLabel(
transfer_frame, text="", font=ctk.CTkFont(size=11),
text_color="#9ca3af", anchor="w",
)
self._transfer_label.pack(fill="x")
self._log = ctk.CTkTextbox(
self, height=80, font=ctk.CTkFont(family="Consolas", size=11),
state="disabled",
)
self._log.pack(fill="x", padx=10, pady=(0, 10))
# ── Server selection ──
def set_server(self, alias: str | None): def set_server(self, alias: str | None):
if self._current_alias == alias:
return
self._disconnect_sftp()
self._current_alias = alias self._current_alias = alias
if alias:
self._connect_sftp()
else:
self._remote_list.populate([])
self._remote_status.configure(text=t("connect_to_browse"))
self._set_remote_buttons_state("disabled")
# ── SFTP connection ──
def _connect_sftp(self):
server = self.store.get_server(self._current_alias)
if not server:
return
self._remote_status.configure(text=t("connecting_sftp"))
self._set_remote_buttons_state("disabled")
def _do():
try:
sftp = SFTPSession(server, self.store.get_ssh_key_path())
sftp.connect()
home = sftp.normalize(".")
self.after(0, lambda: self._on_sftp_connected(sftp, home))
except Exception as e:
self.after(0, lambda: self._on_sftp_error(str(e)))
threading.Thread(target=_do, daemon=True).start()
def _on_sftp_connected(self, sftp: SFTPSession, home: str):
self._sftp = sftp
self._remote_path = home
self._remote_history.clear()
self._remote_status.configure(
text=t("connected_sftp").format(alias=self._current_alias)
)
self._set_remote_buttons_state("normal")
self._refresh_remote()
def _on_sftp_error(self, error: str):
self._remote_status.configure(text=t("sftp_error").format(e=error))
self._log_msg(t("sftp_error").format(e=error))
def _disconnect_sftp(self):
if self._sftp:
try:
self._sftp.disconnect()
except Exception:
pass
self._sftp = None
# ── Local navigation ──
def _refresh_local(self):
self._local_path_entry.delete(0, "end")
self._local_path_entry.insert(0, self._local_path)
try:
entries = os.listdir(self._local_path)
except PermissionError:
self._log_msg(t("permission_denied").format(path=self._local_path))
entries = []
items = []
# Parent dir
parent = os.path.dirname(self._local_path)
if parent != self._local_path:
items.append({"name": "..", "size": "", "date": "", "is_dir": True})
for name in entries:
full = os.path.join(self._local_path, name)
try:
st = os.stat(full)
is_dir = os.path.isdir(full)
items.append({
"name": name,
"size": "" if is_dir else _format_size(st.st_size),
"date": _format_date(st.st_mtime),
"is_dir": is_dir,
})
except (PermissionError, OSError):
items.append({
"name": name, "size": "?", "date": "?", "is_dir": False,
})
self._local_list.populate(items)
count = len([i for i in items if i["name"] != ".."])
self._local_status.configure(text=t("items_count").format(count=count))
def _navigate_local(self, name: str):
if name == "..":
self._local_go_up()
return
target = os.path.join(self._local_path, name)
if os.path.isdir(target):
self._local_history.append(self._local_path)
self._local_path = os.path.normpath(target)
self._refresh_local()
def _local_go_up(self):
parent = os.path.dirname(self._local_path)
if parent != self._local_path:
self._local_history.append(self._local_path)
self._local_path = parent
self._refresh_local()
def _local_go_back(self):
if self._local_history:
self._local_path = self._local_history.pop()
self._refresh_local()
def _local_go_to_path(self):
path = self._local_path_entry.get().strip()
if path and os.path.isdir(path):
self._local_history.append(self._local_path)
self._local_path = os.path.normpath(path)
self._refresh_local()
# ── Remote navigation ──
def _refresh_remote(self):
if not self._sftp or not self._sftp.connected:
return
self._remote_path_entry.delete(0, "end")
self._remote_path_entry.insert(0, self._remote_path)
def _do():
try:
attrs = self._sftp.listdir_attr(self._remote_path)
items = []
# Parent dir
if self._remote_path != "/":
items.append({
"name": "..", "size": "", "date": "", "perm": "", "is_dir": True,
})
for a in attrs:
is_dir = stat.S_ISDIR(a.st_mode) if a.st_mode else False
items.append({
"name": a.filename,
"size": "" if is_dir else _format_size(a.st_size or 0),
"date": _format_date(a.st_mtime or 0),
"perm": _format_perm(a.st_mode & 0o777) if a.st_mode else "",
"is_dir": is_dir,
})
self.after(0, lambda: self._populate_remote(items))
except Exception as e:
self.after(0, lambda: self._on_sftp_error(str(e)))
threading.Thread(target=_do, daemon=True).start()
def _populate_remote(self, items: list[dict]):
self._remote_list.populate(items)
count = len([i for i in items if i["name"] != ".."])
self._remote_status.configure(text=t("items_count").format(count=count))
def _navigate_remote(self, name: str):
if name == "..":
self._remote_go_up()
return
if self._remote_path == "/":
target = "/" + name
else:
target = self._remote_path + "/" + name
self._remote_history.append(self._remote_path)
self._remote_path = target
self._refresh_remote()
def _remote_go_up(self):
if self._remote_path == "/":
return
parent = "/".join(self._remote_path.rstrip("/").split("/")[:-1]) or "/"
self._remote_history.append(self._remote_path)
self._remote_path = parent
self._refresh_remote()
def _remote_go_back(self):
if self._remote_history:
self._remote_path = self._remote_history.pop()
self._refresh_remote()
def _remote_go_to_path(self):
if not self._sftp or not self._sftp.connected:
return
path = self._remote_path_entry.get().strip()
if path:
self._remote_history.append(self._remote_path)
self._remote_path = path
self._refresh_remote()
# ── File operations ──
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:
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()
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:
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"]
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):
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_transfer_done(self):
self._transferring = False
self._progress.set(0)
self._transfer_label.configure(text="")
self._upload_btn.configure(state="normal")
self._download_btn.configure(state="normal")
self._refresh_local()
self._refresh_remote()
def _update_progress(self, fraction: float, text: str):
self._progress.set(fraction)
self._transfer_label.configure(text=text)
def _mkdir_remote(self):
if not self._sftp or not self._sftp.connected:
return
dialog = ctk.CTkInputDialog(
text=t("new_folder_name"), title=t("new_folder"),
)
name = dialog.get_input()
if not name or not name.strip():
return
name = name.strip()
if self._remote_path == "/":
path = "/" + name
else:
path = self._remote_path + "/" + name
def _do():
try:
self._sftp.mkdir(path)
self.after(0, self._refresh_remote)
except Exception as e:
self.after(0, lambda: self._log_msg(t("sftp_error").format(e=str(e))))
threading.Thread(target=_do, daemon=True).start()
def _delete_remote(self):
if not self._sftp or not self._sftp.connected:
return
selected = self._remote_list.get_selected()
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
def _do():
for item in items:
name = item["name"]
if self._remote_path == "/":
path = "/" + name
else:
path = self._remote_path + "/" + name
try:
if item.get("is_dir"):
self._sftp.rmdir(path)
else:
self._sftp.remove(path)
except Exception as e:
self.after(0, lambda: self._log_msg(
t("sftp_error").format(e=str(e))
))
self.after(0, self._refresh_remote)
threading.Thread(target=_do, daemon=True).start()
def _rename_remote(self):
if not self._sftp or not self._sftp.connected:
return
selected = self._remote_list.get_selected()
if not selected or selected[0]["name"] == "..":
return
old_name = selected[0]["name"]
dialog = ctk.CTkInputDialog(
text=t("rename_prompt"), title=t("rename_file"),
)
new_name = dialog.get_input()
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
def _do():
try:
self._sftp.rename(old_path, new_path)
self.after(0, self._refresh_remote)
except Exception as e:
self.after(0, lambda: self._log_msg(t("sftp_error").format(e=str(e))))
threading.Thread(target=_do, daemon=True).start()
# ── Helpers ──
def _set_remote_buttons_state(self, state: str):
for btn in (
self._upload_btn, self._download_btn,
self._mkdir_btn, self._delete_btn, self._rename_btn,
self._remote_refresh_btn, self._remote_back_btn, self._remote_up_btn,
):
btn.configure(state=state)
def _log_msg(self, text: str): def _log_msg(self, text: str):
self.log.configure(state="normal") self._log.configure(state="normal")
self.log.insert("end", text + "\n") self._log.insert("end", text + "\n")
self.log.configure(state="disabled") self._log.configure(state="disabled")
self.log.see("end") self._log.see("end")
def _browse_upload(self):
path = filedialog.askopenfilename()
if path:
self.upload_local.delete(0, "end")
self.upload_local.insert(0, path)
if not self.upload_remote.get():
self.upload_remote.insert(0, "/tmp/" + os.path.basename(path))
def _browse_download(self):
path = filedialog.asksaveasfilename()
if path:
self.download_local.delete(0, "end")
self.download_local.insert(0, path)
def _upload(self):
if not self._current_alias:
self._log_msg(t("no_server_selected"))
return
local = self.upload_local.get().strip()
remote = self.upload_remote.get().strip()
if not local or not remote:
self._log_msg(t("both_paths_required"))
return
if not os.path.exists(local):
self._log_msg(t("file_not_found").format(path=local))
return
server = self.store.get_server(self._current_alias)
if not server:
return
self.upload_btn.configure(state="disabled")
self.progress.set(0)
file_size = os.path.getsize(local)
def _progress(transferred, total):
if total > 0:
self.after(0, lambda: self.progress.set(transferred / total))
def _do():
try:
wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path())
wrapper.upload(local, remote, progress_cb=_progress)
self.after(0, lambda: self._log_msg(t("upload_ok").format(local=local, alias=self._current_alias, remote=remote)))
except Exception as e:
self.after(0, lambda: self._log_msg(f"[ERROR] {e}"))
finally:
self.after(0, lambda: self.upload_btn.configure(state="normal"))
threading.Thread(target=_do, daemon=True).start()
def _download(self):
if not self._current_alias:
self._log_msg(t("no_server_selected"))
return
remote = self.download_remote.get().strip()
local = self.download_local.get().strip()
if not remote or not local:
self._log_msg(t("both_paths_required"))
return
server = self.store.get_server(self._current_alias)
if not server:
return
self.download_btn.configure(state="disabled")
self.progress.set(0)
def _progress(transferred, total):
if total > 0:
self.after(0, lambda: self.progress.set(transferred / total))
def _do():
try:
wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path())
wrapper.download(remote, local, progress_cb=_progress)
self.after(0, lambda: self._log_msg(t("download_ok").format(alias=self._current_alias, remote=remote, local=local)))
except Exception as e:
self.after(0, lambda: self._log_msg(f"[ERROR] {e}"))
finally:
self.after(0, lambda: self.download_btn.configure(state="normal"))
threading.Thread(target=_do, daemon=True).start()

170
gui/widgets/file_list.py Normal file
View File

@@ -0,0 +1,170 @@
"""
FileListWidget — file list based on ttk.Treeview with dark theme styling.
"""
import tkinter as tk
from tkinter import ttk
import customtkinter as ctk
_THEME_APPLIED = False
def _apply_dark_theme():
global _THEME_APPLIED
if _THEME_APPLIED:
return
_THEME_APPLIED = True
style = ttk.Style()
style.theme_use("clam")
style.configure(
"Dark.Treeview",
background="#1e1e1e",
foreground="#dcdcdc",
fieldbackground="#1e1e1e",
borderwidth=0,
font=("Consolas", 11),
rowheight=24,
)
style.configure(
"Dark.Treeview.Heading",
background="#2b2b2b",
foreground="#9ca3af",
borderwidth=0,
font=("Segoe UI", 10, "bold"),
relief="flat",
)
style.map(
"Dark.Treeview",
background=[("selected", "#3b82f6")],
foreground=[("selected", "#ffffff")],
)
style.map(
"Dark.Treeview.Heading",
background=[("active", "#333333")],
)
style.layout("Dark.Treeview", [
("Dark.Treeview.treearea", {"sticky": "nswe"}),
])
class FileListWidget(ctk.CTkFrame):
"""File list with columns, sorting, multi-selection."""
def __init__(self, master, columns: list[tuple[str, int]],
on_navigate=None, on_select=None):
super().__init__(master, fg_color="#1e1e1e", corner_radius=6)
self._on_navigate = on_navigate
self._on_select = on_select
self._items: list[dict] = []
self._sort_col = None
self._sort_reverse = False
_apply_dark_theme()
col_ids = [c[0] for c in columns]
self._tree = ttk.Treeview(
self,
columns=col_ids,
show="headings",
selectmode="extended",
style="Dark.Treeview",
)
for col_name, col_width in columns:
self._tree.heading(
col_name,
text=col_name,
anchor="w",
command=lambda c=col_name: self._sort_by_column(c),
)
self._tree.column(col_name, width=col_width, minwidth=40, anchor="w")
scrollbar = ctk.CTkScrollbar(self, command=self._tree.yview)
self._tree.configure(yscrollcommand=scrollbar.set)
self._tree.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
self._tree.bind("<Double-1>", self._on_double_click)
self._tree.bind("<<TreeviewSelect>>", self._on_tree_select)
self._tree.bind("<Return>", self._on_enter)
def populate(self, items: list[dict]):
"""Fill list with items. Each item: {name, size, date, perm, is_dir}."""
self._items = items
self._tree.delete(*self._tree.get_children())
dirs = [i for i in items if i.get("is_dir")]
files = [i for i in items if not i.get("is_dir")]
dirs.sort(key=lambda x: x["name"].lower())
files.sort(key=lambda x: x["name"].lower())
for item in dirs + files:
prefix = "\U0001F4C1 " if item.get("is_dir") else "\U0001F4C4 "
cols = self._tree.cget("columns")
values = []
for c in cols:
if c == cols[0]: # Name column
values.append(prefix + item.get("name", ""))
elif c.lower() in ("size", "sizes"):
values.append(item.get("size", ""))
elif c.lower() in ("date", "dates"):
values.append(item.get("date", ""))
elif c.lower() in ("perm", "perms"):
values.append(item.get("perm", ""))
else:
values.append(item.get(c.lower(), ""))
iid = self._tree.insert("", "end", values=values)
self._tree.item(iid, tags=("dir",) if item.get("is_dir") else ("file",))
def get_selected(self) -> list[dict]:
"""Return selected items as dicts."""
result = []
for iid in self._tree.selection():
idx = self._tree.index(iid)
dirs = [i for i in self._items if i.get("is_dir")]
files = [i for i in self._items if not i.get("is_dir")]
dirs.sort(key=lambda x: x["name"].lower())
files.sort(key=lambda x: x["name"].lower())
ordered = dirs + files
if 0 <= idx < len(ordered):
result.append(ordered[idx])
return result
def _on_double_click(self, event):
sel = self.get_selected()
if sel and sel[0].get("is_dir") and self._on_navigate:
self._on_navigate(sel[0]["name"])
def _on_enter(self, event):
sel = self.get_selected()
if sel and sel[0].get("is_dir") and self._on_navigate:
self._on_navigate(sel[0]["name"])
def _on_tree_select(self, event):
if self._on_select:
self._on_select(self.get_selected())
def _sort_by_column(self, col):
if self._sort_col == col:
self._sort_reverse = not self._sort_reverse
else:
self._sort_col = col
self._sort_reverse = False
cols = self._tree.cget("columns")
col_map = {cols[0]: "name"}
for c in cols[1:]:
col_map[c] = c.lower()
field = col_map.get(col, "name")
dirs = [i for i in self._items if i.get("is_dir")]
files = [i for i in self._items if not i.get("is_dir")]
dirs.sort(key=lambda x: str(x.get(field, "")).lower(), reverse=self._sort_reverse)
files.sort(key=lambda x: str(x.get(field, "")).lower(), reverse=self._sort_reverse)
self._items = dirs + files
self.populate(self._items)

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager.""" """Version info for ServerManager."""
__version__ = "1.6.2" __version__ = "1.7.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"