SXXXXXXX_PyUCC/pyucc/gui/profile_manager.py

490 lines
18 KiB
Python

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from pathlib import Path
from typing import List
import tkinter.simpledialog as simpledialog
from ..config import profiles as profiles_cfg
from ..config.languages import LANGUAGE_EXTENSIONS
class ProfileManager(tk.Toplevel):
"""Dialog window to create/edit/delete scanning profiles.
Usage: pm = ProfileManager(root); pm.wait_window()
"""
COMMON_LANGS = ["Python", "C", "C++", "Java", "JavaScript", "HTML", "Shell"]
# sensible defaults for ignore patterns covering common build/artifact dirs and files
DEFAULT_IGNORES = [
"__pycache__",
"*.pyc",
"*.pyo",
"*.pyd",
".Python",
"env",
"venv",
".venv",
"build",
"dist",
"*.egg-info",
".eggs",
"node_modules",
".git",
".hg",
".svn",
".idea",
".vscode",
".DS_Store",
"*.class",
"*.o",
"*.so",
"*.dylib",
".pytest_cache",
".mypy_cache",
".cache",
"coverage",
".tox",
"pip-wheel-metadata",
# additional commons
"*.log",
"*.tmp",
"*.bak",
"*.backup",
"*~",
"*.swp",
"*.swo",
"Thumbs.db",
]
def __init__(self, parent, on_change=None):
super().__init__(parent)
self.title("Profile Manager")
self.geometry("1100x700")
self.on_change = on_change
self.profiles = profiles_cfg.load_profiles()
# Left: list of profiles
left = ttk.Frame(self)
left.pack(side="left", fill="y", padx=8, pady=8)
# Make listbox taller so names are easily visible
self.listbox = tk.Listbox(left, width=30, height=20)
self.listbox.pack(fill="y", expand=True)
for p in self.profiles:
self.listbox.insert("end", p.get("name"))
self.listbox.bind("<<ListboxSelect>>", self._on_select)
# Right: edit form
right = ttk.Frame(self)
right.pack(side="left", fill="both", expand=True, padx=8, pady=8)
ttk.Label(right, text="Profile name:").grid(row=0, column=0, sticky="w", pady=4)
self.name_var = tk.StringVar()
ttk.Entry(right, textvariable=self.name_var, width=48).grid(
row=0, column=1, columnspan=2, sticky="ew", pady=4
)
# Create two titled frames side-by-side: one for Paths and one for Extensions
container = ttk.Frame(right)
container.grid(row=2, column=0, columnspan=3, sticky="nsew", pady=2)
# Left labeled frame for Paths
self.paths_frame = ttk.LabelFrame(container, text="Paths (files/folders):")
self.paths_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
# Right labeled frame for Extensions
self.ext_frame = ttk.LabelFrame(container, text="Filter Extensions:")
self.ext_frame.grid(row=0, column=1, sticky="nsew")
# Paths listbox + scrollbar inside its frame
self.paths_listbox = tk.Listbox(self.paths_frame, height=12)
self.paths_listbox.grid(row=0, column=0, sticky="nsew")
paths_vsb = ttk.Scrollbar(
self.paths_frame, orient="vertical", command=self.paths_listbox.yview
)
paths_vsb.grid(row=0, column=1, sticky="ns")
self.paths_listbox.configure(yscrollcommand=paths_vsb.set)
# Buttons for paths inside paths_frame
btn_w = 14
paths_btn_frame = ttk.Frame(self.paths_frame)
paths_btn_frame.grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 0))
ttk.Button(
paths_btn_frame, text="📁 Add Folder", command=self._add_folder, width=btn_w
).grid(row=0, column=0, padx=6, pady=4)
ttk.Button(
paths_btn_frame, text="📄 Add File(s)", command=self._add_files, width=btn_w
).grid(row=0, column=1, padx=6, pady=4)
ttk.Button(
paths_btn_frame,
text="✏️ Edit Selected",
command=self._edit_selected_path,
width=btn_w,
).grid(row=0, column=2, padx=6, pady=4)
ttk.Button(
paths_btn_frame,
text="🗑️ Remove Selected",
command=self._remove_selected_path,
width=btn_w,
).grid(row=0, column=3, padx=6, pady=4)
ttk.Button(
paths_btn_frame,
text="⬆️ Move Up",
command=self._move_selected_up,
width=btn_w,
).grid(row=1, column=0, padx=6, pady=4)
ttk.Button(
paths_btn_frame,
text="⬇️ Move Down",
command=self._move_selected_down,
width=btn_w,
).grid(row=1, column=1, padx=6, pady=4)
# Extensions listbox + scrollbar inside its frame
self.ext_listbox = tk.Listbox(self.ext_frame, selectmode="extended", height=12)
self.ext_listbox.grid(row=0, column=0, sticky="nsew")
ext_vsb = ttk.Scrollbar(
self.ext_frame, orient="vertical", command=self.ext_listbox.yview
)
ext_vsb.grid(row=0, column=1, sticky="ns")
self.ext_listbox.configure(yscrollcommand=ext_vsb.set)
# Clear selection button inside ext_frame
ttk.Button(
self.ext_frame,
text="🧹 Clear Selection",
command=self._clear_ext_selection,
width=12,
).grid(row=1, column=0, pady=(8, 0))
# layout weights so both boxes have same height and paths take most width
container.columnconfigure(0, weight=4)
container.columnconfigure(1, weight=1)
container.rowconfigure(0, weight=1)
self.paths_frame.columnconfigure(0, weight=1)
self.paths_frame.rowconfigure(0, weight=1)
self.ext_frame.columnconfigure(0, weight=1)
self.ext_frame.rowconfigure(0, weight=1)
# (languages selection is handled by the Filter Extensions listbox at right)
# Custom languages: label above large text box spanning full width
ttk.Label(right, text="Custom languages (comma-separated):").grid(
row=4, column=0, sticky="w", pady=(8, 2)
)
self.custom_text = tk.Text(right, height=3, wrap="none")
self.custom_text.grid(row=5, column=0, columnspan=3, sticky="nsew", pady=2)
# Ignore patterns: label above large text box with scrollbars
ttk.Label(right, text="Ignore patterns (comma-separated):").grid(
row=6, column=0, sticky="w", pady=(8, 2)
)
ignore_container = ttk.Frame(right)
ignore_container.grid(row=7, column=0, columnspan=3, sticky="nsew", pady=2)
# Text widget with both vertical and horizontal scrollbars so long patterns are readable
self.ignore_text = tk.Text(ignore_container, height=4, wrap="none")
self.ignore_text.grid(row=0, column=0, sticky="nsew")
vsb_ignore = ttk.Scrollbar(
ignore_container, orient="vertical", command=self.ignore_text.yview
)
vsb_ignore.grid(row=0, column=1, sticky="ns")
hsb_ignore = ttk.Scrollbar(
ignore_container, orient="horizontal", command=self.ignore_text.xview
)
hsb_ignore.grid(row=1, column=0, columnspan=2, sticky="ew")
self.ignore_text.configure(
yscrollcommand=vsb_ignore.set, xscrollcommand=hsb_ignore.set
)
ignore_container.columnconfigure(0, weight=1)
ignore_container.rowconfigure(0, weight=1)
# Buttons (place below the large edit boxes)
btn_frame = ttk.Frame(right)
btn_frame.grid(row=8, column=0, columnspan=3, pady=(12, 0))
# Button to insert default ignore patterns into the ignore box
ttk.Button(
btn_frame,
text="⚙️ Default Ignores",
command=self._apply_default_ignores,
).grid(row=0, column=0, padx=4)
ttk.Button(btn_frame, text="📝 New", command=self._new).grid(
row=0, column=1, padx=4
)
ttk.Button(btn_frame, text="💾 Save", command=self._save).grid(
row=0, column=2, padx=4
)
ttk.Button(btn_frame, text="Delete", command=self._delete).grid(
row=0, column=3, padx=4
)
ttk.Button(btn_frame, text="Close", command=self.destroy).grid(
row=0, column=4, padx=4
)
right.columnconfigure(1, weight=1)
# If no profiles exist, prefill form with sensible defaults (widgets are ready)
if not self.profiles:
self._new()
# populate extension listbox
try:
self._populate_ext_listbox()
except Exception:
pass
def _browse_path(self):
d = filedialog.askdirectory()
if d:
# convenience: add as a path entry
self.paths_listbox.insert("end", str(Path(d)))
def _add_folder(self):
d = filedialog.askdirectory()
if d:
self._insert_path_display("end", str(Path(d)))
def _add_files(self):
files = filedialog.askopenfilenames()
for f in files:
if f:
self._insert_path_display("end", str(Path(f)))
def _populate_ext_listbox(self):
# Populate extension listbox with LANGUAGE_EXTENSIONS entries
try:
from ..config.languages import LANGUAGE_EXTENSIONS
except Exception:
return
self.ext_listbox.delete(0, "end")
keys = sorted(LANGUAGE_EXTENSIONS.keys(), key=lambda s: s.lower())
for k in keys:
exts = LANGUAGE_EXTENSIONS.get(k) or []
exts_label = f" ({', '.join(exts)})" if exts else ""
self.ext_listbox.insert("end", f"{k}{exts_label}")
def _clear_ext_selection(self):
self.ext_listbox.selection_clear(0, "end")
def _edit_selected_path(self):
sel = self.paths_listbox.curselection()
if not sel:
return
idx = sel[0]
current = self.paths_listbox.get(idx)
# strip prefix for editing
stripped = self._strip_display_prefix(current)
new = simpledialog.askstring(
"Edit Path", "Path:", initialvalue=stripped, parent=self
)
if new:
self.paths_listbox.delete(idx)
self._insert_path_display(idx, new)
def _remove_selected_path(self):
sel = list(self.paths_listbox.curselection())
# remove from end to start to keep indices valid
for i in reversed(sel):
self.paths_listbox.delete(i)
def _move_selected_up(self):
sel = list(self.paths_listbox.curselection())
if not sel:
return
for i in sel:
if i == 0:
continue
txt = self.paths_listbox.get(i)
above = self.paths_listbox.get(i - 1)
self.paths_listbox.delete(i - 1, i)
self.paths_listbox.insert(i - 1, txt)
self.paths_listbox.insert(i, above)
# restore selection
self.paths_listbox.selection_clear(0, "end")
for i in [max(0, x - 1) for x in sel]:
self.paths_listbox.selection_set(i)
def _move_selected_down(self):
sel = list(self.paths_listbox.curselection())
if not sel:
return
size = self.paths_listbox.size()
for i in reversed(sel):
if i >= size - 1:
continue
txt = self.paths_listbox.get(i)
below = self.paths_listbox.get(i + 1)
self.paths_listbox.delete(i, i + 1)
self.paths_listbox.insert(i, below)
self.paths_listbox.insert(i + 1, txt)
# restore selection
self.paths_listbox.selection_clear(0, "end")
for i in [min(size - 1, x + 1) for x in sel]:
self.paths_listbox.selection_set(i)
def _insert_path_display(self, index, path_str: str):
# choose a prefix depending on whether path is dir/file/unknown
p = Path(path_str)
if p.exists():
prefix = "[D] " if p.is_dir() else "[F] "
else:
prefix = "[?] "
display = f"{prefix}{path_str}"
self.paths_listbox.insert(index, display)
def _strip_display_prefix(self, display_text: str) -> str:
# remove known prefixes like [D] , [F] , [?] if present
if (
display_text.startswith("[D] ")
or display_text.startswith("[F] ")
or display_text.startswith("[?] ")
):
return display_text[4:]
return display_text
def _get_paths_from_listbox(self) -> List[str]:
out = []
for i in range(self.paths_listbox.size()):
raw = self._strip_display_prefix(self.paths_listbox.get(i))
out.append(raw)
return out
def _on_select(self, _evt=None):
sel = self.listbox.curselection()
if not sel:
return
idx = sel[0]
pr = self.profiles[idx]
self._load_profile(pr)
def _load_profile(self, pr):
self.name_var.set(pr.get("name", ""))
# Load only new-style 'paths' list (no legacy compatibility)
self.paths_listbox.delete(0, "end")
paths = pr.get("paths") or []
for p in paths:
self._insert_path_display("end", str(p))
langs = pr.get("languages", []) or []
# set ext_listbox selection according to languages saved in profile
try:
self.ext_listbox.selection_clear(0, "end")
keys = [
self.ext_listbox.get(i).split(" (", 1)[0]
for i in range(self.ext_listbox.size())
]
for i, name in enumerate(keys):
if name in langs:
self.ext_listbox.selection_set(i)
custom = ",".join([l for l in langs if l not in keys])
except Exception:
custom = ",".join([l for l in langs if l not in self.COMMON_LANGS])
# populate custom_text (large edit box)
self.custom_text.delete("1.0", "end")
if custom:
self.custom_text.insert("1.0", custom)
# populate ignore_text
self.ignore_text.delete("1.0", "end")
ignores = ",".join(pr.get("ignore", []))
if ignores:
self.ignore_text.insert("1.0", ignores)
def _new(self):
self.name_var.set("")
self.paths_listbox.delete(0, "end")
try:
self.ext_listbox.selection_clear(0, "end")
except Exception:
pass
self.custom_text.delete("1.0", "end")
self.ignore_text.delete("1.0", "end")
# insert the DEFAULT_IGNORES joined by commas
self.ignore_text.insert("1.0", ",".join(self.DEFAULT_IGNORES))
def _apply_default_ignores(self):
"""Merge current ignore patterns with the DEFAULT_IGNORES and
write the resulting comma-separated list into the ignore_text box.
Duplicates are removed while preserving order (existing patterns kept first).
"""
try:
raw = self.ignore_text.get("1.0", "end").strip()
raw = raw.replace("\n", ",")
current = [s.strip() for s in raw.split(",") if s.strip()]
result = []
# start with existing entries
for it in current:
if it not in result:
result.append(it)
# append defaults if not present
for d in self.DEFAULT_IGNORES:
if d not in result:
result.append(d)
# write back
self.ignore_text.delete("1.0", "end")
self.ignore_text.insert("1.0", ",".join(result))
except Exception:
# fallback: just set defaults
self.ignore_text.delete("1.0", "end")
self.ignore_text.insert("1.0", ",".join(self.DEFAULT_IGNORES))
def _save(self):
name = self.name_var.get().strip()
if not name:
messagebox.showwarning("Validation", "Profile must have a name.")
return
# collect paths from listbox
paths = self._get_paths_from_listbox()
# validation: if no paths, ask confirmation
if not paths:
if not messagebox.askyesno(
"Confirm", "Paths list is empty. Save profile anyway?"
):
return
# ensure at least one of the paths exists on filesystem
existing = [p for p in paths if Path(p).exists()]
if not existing and paths:
messagebox.showwarning(
"Validation",
"None of the configured paths exist. Please add at least one existing path.",
)
return
# languages come from extension listbox selection
langs: List[str] = []
try:
sel = [self.ext_listbox.get(i) for i in self.ext_listbox.curselection()]
for s in sel:
# s is like 'Python (.py, .pyw)'
lang_name = s.split(" (", 1)[0]
if lang_name and lang_name not in langs:
langs.append(lang_name)
except Exception:
pass
raw_custom = self.custom_text.get("1.0", "end").strip()
raw_custom = raw_custom.replace("\n", ",")
custom = [c.strip() for c in raw_custom.split(",") if c.strip()]
langs.extend([c for c in custom if c])
raw_ignore = self.ignore_text.get("1.0", "end").strip()
raw_ignore = raw_ignore.replace("\n", ",")
ignore = [s.strip() for s in raw_ignore.split(",") if s.strip()]
profile = {"name": name, "paths": paths, "languages": langs, "ignore": ignore}
profiles_cfg.add_or_update_profile(profile)
self.profiles = profiles_cfg.load_profiles()
# refresh listbox
self.listbox.delete(0, "end")
for p in self.profiles:
self.listbox.insert("end", p.get("name"))
messagebox.showinfo("Saved", f"Profile '{name}' saved.")
if self.on_change:
self.on_change()
def _delete(self):
name = self.name_var.get().strip()
if not name:
return
if not messagebox.askyesno("Delete", f"Delete profile '{name}'?"):
return
profiles_cfg.delete_profile(name)
self.profiles = profiles_cfg.load_profiles()
self.listbox.delete(0, "end")
for p in self.profiles:
self.listbox.insert("end", p.get("name"))
self._new()
if self.on_change:
self.on_change()