485 lines
18 KiB
Python
485 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",
|
|
"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()
|