import tkinter as tk from tkinter import ttk, messagebox, filedialog from pathlib import Path from typing import List 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", ] def __init__(self, parent, on_change=None): super().__init__(parent) self.title("Profile Manager") self.geometry("900x600") 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("<>", 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) # Path: label above large entry spanning full right area ttk.Label(right, text="Path:").grid(row=1, column=0, sticky="w", pady=(8, 2)) self.path_var = tk.StringVar() ttk.Entry(right, textvariable=self.path_var, width=80).grid(row=2, column=0, columnspan=2, sticky="ew", pady=2) ttk.Button(right, text="Browse...", command=self._browse_path).grid(row=2, column=2, sticky="w", padx=4) ttk.Label(right, text="Languages:").grid(row=3, column=0, sticky="nw", pady=(8, 2)) # Scrollable frame for languages so checkboxes remain readable langs_container = ttk.Frame(right) langs_container.grid(row=3, column=1, columnspan=2, sticky="ew", pady=4) canvas = tk.Canvas(langs_container, height=140) vsb = ttk.Scrollbar(langs_container, orient="vertical", command=canvas.yview) inner = ttk.Frame(canvas) inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=vsb.set) canvas.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") langs_container.columnconfigure(0, weight=1) self.lang_vars = {} for i, ln in enumerate(self.COMMON_LANGS): var = tk.BooleanVar() exts = LANGUAGE_EXTENSIONS.get(ln, []) exts_label = f" ({', '.join(exts)})" if exts else "" cb = ttk.Checkbutton(inner, text=ln + exts_label, variable=var) cb.grid(row=i, column=0, sticky="w", padx=2, pady=2) self.lang_vars[ln] = var # 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)) ttk.Button(btn_frame, text="New", command=self._new).grid(row=0, column=0, padx=4) ttk.Button(btn_frame, text="Save", command=self._save).grid(row=0, column=1, padx=4) ttk.Button(btn_frame, text="Delete", command=self._delete).grid(row=0, column=2, padx=4) ttk.Button(btn_frame, text="Close", command=self.destroy).grid(row=0, column=3, 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() def _browse_path(self): d = filedialog.askdirectory() if d: self.path_var.set(str(Path(d))) 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", "")) self.path_var.set(pr.get("path", "")) langs = pr.get("languages", []) or [] for ln, var in self.lang_vars.items(): var.set(ln in langs) 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.path_var.set("") for var in self.lang_vars.values(): var.set(False) 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 _save(self): name = self.name_var.get().strip() if not name: messagebox.showwarning("Validation", "Profile must have a name.") return path = self.path_var.get().strip() langs: List[str] = [ln for ln, var in self.lang_vars.items() if var.get()] 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, "path": path, "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()