SXXXXXXX_PyUCC/pyucc/gui/profile_manager.py

646 lines
24 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
# currently loaded profile dict (or None for new)
self._loaded_profile = None
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, exportselection=False)
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, exportselection=False)
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._on_close).grid(
row=0, column=4, padx=4
)
# handle window close (X) the same way as Close button
try:
self.protocol("WM_DELETE_WINDOW", self._on_close)
except Exception:
pass
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 _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 _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 _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 = self._ask_edit_path(stripped)
if new:
self.paths_listbox.delete(idx)
self._insert_path_display(idx, new)
def _ask_edit_path(self, initial: str) -> str:
"""Open a small modal dialog with a larger entry widget for editing paths.
Returns the edited string, or None if cancelled.
"""
dlg = tk.Toplevel(self)
dlg.title("Edit Path")
dlg.transient(self)
dlg.grab_set()
frm = ttk.Frame(dlg)
frm.grid(row=0, column=0, padx=8, pady=8, sticky="nsew")
ttk.Label(frm, text="Path:").grid(row=0, column=0, columnspan=2, sticky="w")
val = tk.StringVar(value=initial)
entry = ttk.Entry(frm, textvariable=val, width=90)
entry.grid(row=1, column=0, sticky="ew", pady=(6, 0))
def _on_browse():
d = filedialog.askdirectory(parent=dlg)
if d:
val.set(str(Path(d)))
entry.focus_set()
entry.selection_range(0, 'end')
browse_btn = ttk.Button(frm, text="Browse...", command=_on_browse)
browse_btn.grid(row=1, column=1, padx=(6, 0), pady=(6, 0))
btns = ttk.Frame(frm)
btns.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky="e")
ok_pressed = {"ok": False}
def _on_ok():
ok_pressed["ok"] = True
try:
dlg.destroy()
except Exception:
pass
def _on_cancel():
try:
dlg.destroy()
except Exception:
pass
ttk.Button(btns, text="OK", command=_on_ok).grid(row=0, column=0, padx=6)
ttk.Button(btns, text="Cancel", command=_on_cancel).grid(row=0, column=1)
frm.columnconfigure(0, weight=1)
# center dialog above parent (ProfileManager)
try:
dlg.update_idletasks()
pwx = self.winfo_rootx()
pwy = self.winfo_rooty()
pww = self.winfo_width()
pwh = self.winfo_height()
dw = dlg.winfo_width()
dh = dlg.winfo_height()
cx = pwx + max(0, (pww - dw) // 2)
cy = pwy + max(0, (pwh - dh) // 2)
dlg.geometry(f"+{cx}+{cy}")
except Exception:
pass
entry.focus_set()
entry.selection_range(0, 'end')
self.wait_window(dlg)
if ok_pressed["ok"]:
return val.get().strip()
return None
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", ""))
# remember loaded profile so we can detect unsaved changes on close
self._loaded_profile = dict(pr)
# 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))
# new form — no loaded profile
self._loaded_profile = None
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()
# mark this as the currently loaded profile
try:
self._loaded_profile = dict(profile)
except Exception:
self._loaded_profile = None
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()
self._loaded_profile = None
def _on_close(self):
"""Handle window close: if form differs from loaded profile, ask to save."""
# build current form representation
try:
cur_name = self.name_var.get().strip()
cur_paths = self._get_paths_from_listbox()
cur_langs = []
try:
sel = [self.ext_listbox.get(i) for i in self.ext_listbox.curselection()]
for s in sel:
lang_name = s.split(" (", 1)[0]
if lang_name and lang_name not in cur_langs:
cur_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()]
cur_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", ",")
cur_ignore = [s.strip() for s in raw_ignore.split(",") if s.strip()]
cur_profile = {
"name": cur_name,
"paths": cur_paths,
"languages": cur_langs,
"ignore": cur_ignore,
}
except Exception:
cur_profile = None
changed = False
if self._loaded_profile is None and any([v for v in (cur_name, cur_paths, cur_langs, cur_ignore) if v]):
changed = True
elif self._loaded_profile is not None and cur_profile is not None:
# compare simple dicts
# note: do not attempt deep-normalization beyond this
if (
self._loaded_profile.get("name") != cur_profile.get("name")
or self._loaded_profile.get("paths") != cur_profile.get("paths")
or self._loaded_profile.get("languages") != cur_profile.get("languages")
or self._loaded_profile.get("ignore") != cur_profile.get("ignore")
):
changed = True
if changed:
ans = messagebox.askyesnocancel(
"Unsaved Changes", "Save changes to profile before closing?"
)
# True => save, False => discard and close, None => cancel
if ans is None:
return
if ans:
try:
self._save()
except Exception:
# if save fails, don't close
return
try:
self.destroy()
except Exception:
try:
super().destroy()
except Exception:
pass