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:
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)
|
||||
Reference in New Issue
Block a user