- 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>
225 lines
7.7 KiB
Python
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)
|