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:
84
core/i18n.py
84
core/i18n.py
@@ -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 = {
|
||||||
|
|||||||
@@ -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("'", "'\\''") + "'"
|
||||||
|
|||||||
@@ -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
170
gui/widgets/file_list.py
Normal 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)
|
||||||
BIN
releases/ServerManager-v1.7.0-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.7.0-win-x64.exe
Normal file
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user