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", ] 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("<>", 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)) 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() # 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 _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()