Files
server-manager/gui/widgets/file_list.py
chrome-storm-c442 3e9aeababe 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>
2026-02-23 16:30:56 -05:00

225 lines
7.7 KiB
Python

"""
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, drag-and-drop."""
def __init__(self, master, columns: list[tuple[str, int]],
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()
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)
# 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
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)
# ── 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)