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

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)