From ae53019b239cbb1bd6faaf21f093af7caf0b04b0 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Tue, 23 Dec 2025 10:32:22 +0100 Subject: [PATCH] modificata la gui ed aggiunto sistema profili --- codebridge/__main__.py | 14 ++ codebridge/core/comparer.py | 32 ++++- codebridge/gui/commit_dialog.py | 4 +- codebridge/gui/diff_viewer.py | 2 +- codebridge/gui/main_window.py | 215 +++++++++++++++++++++++++++-- codebridge/gui/profile_dialog.py | 223 +++++++++++++++++++++++++++++++ profiles.json | 47 +++++++ 7 files changed, 523 insertions(+), 14 deletions(-) create mode 100644 codebridge/gui/profile_dialog.py create mode 100644 profiles.json diff --git a/codebridge/__main__.py b/codebridge/__main__.py index 849b709..d483694 100644 --- a/codebridge/__main__.py +++ b/codebridge/__main__.py @@ -1,4 +1,18 @@ +import os +import sys import tkinter as tk + +# Ensure `externals` submodules are importable at runtime by adding +# their directories to sys.path. This keeps the externals as lightweight +# submodules without requiring packaging. +ROOT = os.path.dirname(os.path.dirname(__file__)) +EXT_DIR = os.path.join(ROOT, "externals") +TKLOGGER_PATH = os.path.join(EXT_DIR, "python-tkinter-logger") +RESMON_PATH = os.path.join(EXT_DIR, "python-resource-monitor") +for p in (TKLOGGER_PATH, RESMON_PATH): + if os.path.isdir(p) and p not in sys.path: + sys.path.insert(0, p) + from codebridge.gui.main_window import MainWindow diff --git a/codebridge/core/comparer.py b/codebridge/core/comparer.py index d39c4de..1e84340 100644 --- a/codebridge/core/comparer.py +++ b/codebridge/core/comparer.py @@ -21,9 +21,23 @@ class CodeComparer: "thumbs.db" } - def __init__(self, source_path: str, destination_path: str): + def __init__(self, source_path: str, destination_path: str, ignore_extensions=None): self.source_path = source_path self.destination_path = destination_path + # ignore_extensions: iterable of extensions (including dot), e.g. ['.o', '.d'] + # Normalize: strip whitespace, ensure leading dot, lowercase + norm = set() + for e in (ignore_extensions or []): + try: + s = str(e).strip() + except Exception: + continue + if not s: + continue + if not s.startswith('.'): + s = '.' + s + norm.add(s.lower()) + self.ignore_extensions = norm self.added: List[str] = [] self.modified: List[str] = [] self.deleted: List[str] = [] @@ -32,10 +46,22 @@ class CodeComparer: """ Checks if a file or directory name matches the ignore patterns. """ + # exact-name ignores if name in self.IGNORE_PATTERNS: return True - if name.endswith(".pyc"): - return True + + # wildcard-like patterns in IGNORE_PATTERNS (simple suffix check for patterns like '*.pyc') + for pat in self.IGNORE_PATTERNS: + if pat.startswith("*") and name.lower().endswith(pat.lstrip("*").lower()): + return True + + # explicit extension ignores provided at runtime + # compare case-insensitive + lname = name.lower() + for ext in self.ignore_extensions: + if ext and lname.endswith(ext): + return True + return False def compute_hash(self, file_path: str) -> str: diff --git a/codebridge/gui/commit_dialog.py b/codebridge/gui/commit_dialog.py index ebc90c4..15ee711 100644 --- a/codebridge/gui/commit_dialog.py +++ b/codebridge/gui/commit_dialog.py @@ -27,9 +27,9 @@ class CommitDialog(tk.Toplevel): self.text_area.pack(padx=10, pady=5, fill="both", expand=True) btn_frame = ttk.Frame(self) btn_frame.pack(fill="x", pady=10) - ok_btn = ttk.Button(btn_frame, text="Confirm", command=self._on_confirm) + ok_btn = ttk.Button(btn_frame, text="✅ Confirm", command=self._on_confirm) ok_btn.pack(side="right", padx=10) - cancel_btn = ttk.Button(btn_frame, text="Cancel", command=self.destroy) + cancel_btn = ttk.Button(btn_frame, text="✖ Cancel", command=self.destroy) cancel_btn.pack(side="right", padx=5) def _on_confirm(self) -> None: diff --git a/codebridge/gui/diff_viewer.py b/codebridge/gui/diff_viewer.py index 92501f7..006a9dc 100644 --- a/codebridge/gui/diff_viewer.py +++ b/codebridge/gui/diff_viewer.py @@ -27,7 +27,7 @@ class DiffViewer(tk.Toplevel): # Toolbar toolbar = ttk.Frame(self, padding=5) toolbar.pack(fill="x", side="top") - copy_all_btn = ttk.Button(toolbar, text="Copy Source to Destination", command=self._copy_source_to_dest) + copy_all_btn = ttk.Button(toolbar, text="⤴ Copy Source to Destination", command=self._copy_source_to_dest) copy_all_btn.pack(side="left", padx=5) # Main diff container main_frame = ttk.Frame(self) diff --git a/codebridge/gui/main_window.py b/codebridge/gui/main_window.py index 0d39457..f134db8 100644 --- a/codebridge/gui/main_window.py +++ b/codebridge/gui/main_window.py @@ -1,10 +1,26 @@ import tkinter as tk from tkinter import ttk, filedialog, messagebox +from tkinter.scrolledtext import ScrolledText import os +import logging + +# Externals (made importable by __main__.py adding their folders to sys.path) +try: + from tkinter_logger import TkinterLogger +except Exception: + TkinterLogger = None # type: ignore + +try: + from resource_monitor import TkinterResourceMonitor, is_psutil_available +except Exception: + TkinterResourceMonitor = None # type: ignore + is_psutil_available = lambda: False from codebridge.core.comparer import CodeComparer from codebridge.core.sync_manager import SyncManager from codebridge.gui.diff_viewer import DiffViewer from codebridge.gui.commit_dialog import CommitDialog +from codebridge.gui.profile_dialog import ProfileManagerDialog, ProfileManagerFrame +import json class MainWindow: @@ -18,7 +34,58 @@ class MainWindow: self.root.geometry("900x600") self.comparer = None self.sync_manager = SyncManager() + # Initialize optional externals + self.logger_sys = None + if TkinterLogger is not None: + try: + self.logger_sys = TkinterLogger(self.root) + # enable console by default; tkinter handler will be added when log window opens + self.logger_sys.setup(enable_console=True, enable_tkinter=True) + logging.getLogger(__name__).info("TkinterLogger initialized") + except Exception: + self.logger_sys = None + + # Resource monitor + self._resource_monitor = None + self.status_var = tk.StringVar() + if TkinterResourceMonitor is not None and is_psutil_available(): + try: + self._resource_monitor = TkinterResourceMonitor(self.root, self.status_var, poll_interval=1.0) + self._resource_monitor.start() + except Exception: + self._resource_monitor = None + else: + # psutil not available -> show note + self.status_var.set("Resource monitor: psutil not installed") + + # Profiles: load/save path in program root + try: + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + except Exception: + repo_root = os.getcwd() + self._profiles_path = os.path.join(repo_root, "profiles.json") + self.profiles = self._load_profiles() + self.current_ignore_exts = [] + + # Setup UI and close handler self._setup_ui() + # Attach logger handler to persistent log widget if available + try: + if self.logger_sys and hasattr(self, "log_text"): + try: + self.logger_sys.add_tkinter_handler(self.log_text) + except Exception: + logging.getLogger(__name__).exception("Failed to attach tkinter log handler to main UI") + except Exception: + pass + + # Populate profiles combobox if any + try: + self._populate_profiles() + except Exception: + pass + + self.root.protocol("WM_DELETE_WINDOW", self._on_closing) def _setup_ui(self) -> None: """ @@ -27,6 +94,15 @@ class MainWindow: # Path selection frame path_frame = ttk.LabelFrame(self.root, text="Path Selection", padding=10) path_frame.pack(fill="x", padx=10, pady=5) + # Profiles combobox + Manage button + prof_frame = ttk.Frame(path_frame) + prof_frame.grid(row=0, column=0, columnspan=3, sticky="ew", pady=(0,6)) + ttk.Label(prof_frame, text="Profile:").pack(side="left") + self.profile_var = tk.StringVar() + self.profile_cb = ttk.Combobox(prof_frame, textvariable=self.profile_var, state="readonly", width=40) + self.profile_cb.pack(side="left", padx=6) + self.profile_cb.bind("<>", lambda e: self._on_profile_change()) + ttk.Button(prof_frame, text="⚙️ Manage Profiles...", command=self._open_manage_profiles).pack(side="left") self.src_path_var = tk.StringVar() self.dst_path_var = tk.StringVar() self._create_path_row(path_frame, "Source (New):", self.src_path_var, 0) @@ -34,11 +110,11 @@ class MainWindow: # Action buttons btn_frame = ttk.Frame(self.root, padding=5) btn_frame.pack(fill="x", padx=10) - compare_btn = ttk.Button(btn_frame, text="Compare Folders", command=self._run_comparison) + compare_btn = ttk.Button(btn_frame, text="🔍 Compare Folders", command=self._run_comparison) compare_btn.pack(side="left", padx=5) - export_btn = ttk.Button(btn_frame, text="Export Changes (ZIP)", command=self._export_changes) + export_btn = ttk.Button(btn_frame, text="📦 Export Changes (ZIP)", command=self._export_changes) export_btn.pack(side="left", padx=5) - import_btn = ttk.Button(btn_frame, text="Import Package (ZIP)", command=self._import_package) + import_btn = ttk.Button(btn_frame, text="📥 Import Package (ZIP)", command=self._import_package) import_btn.pack(side="left", padx=5) # Results treeview self._setup_treeview() @@ -47,9 +123,18 @@ class MainWindow: """ Creates a row with a label, entry and browse button for paths. """ - ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=5) - ttk.Entry(parent, textvariable=var, width=80).grid(row=row, column=1, padx=5, pady=2) - ttk.Button(parent, text="Browse", command=lambda: self._browse_folder(var)).grid(row=row, column=2, padx=5) + ttk.Label(parent, text=label).grid(row=row+1, column=0, sticky="w", padx=5) + entry = ttk.Entry(parent, textvariable=var, width=80) + entry.grid(row=row+1, column=1, padx=5, pady=2) + btn = ttk.Button(parent, text="📁 Browse", command=lambda: self._browse_folder(var)) + btn.grid(row=row+1, column=2, padx=5) + # store references so they can be made readonly when a profile is selected + if label.lower().startswith("source"): + self.src_entry = entry + self.src_browse = btn + else: + self.dst_entry = entry + self.dst_browse = btn def _browse_folder(self, var: tk.StringVar) -> None: """ @@ -79,6 +164,92 @@ class MainWindow: self.tree.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") + # Persistent log box below the treeview + log_frame = ttk.Frame(self.root, padding=(5, 2)) + log_frame.pack(fill="x") + self.log_text = ScrolledText(log_frame, height=8, state=tk.DISABLED) + self.log_text.pack(fill="both", expand=True) + + # Status bar at bottom + status_frame = ttk.Frame(self.root, padding=(5, 2)) + status_frame.pack(fill="x", side="bottom") + ttk.Label(status_frame, textvariable=self.status_var).pack(side="left") + + # --- Profiles persistence --- + def _load_profiles(self): + try: + if os.path.exists(self._profiles_path): + with open(self._profiles_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + def _save_profiles(self): + try: + with open(self._profiles_path, "w", encoding="utf-8") as f: + json.dump(self.profiles, f, indent=2) + except Exception: + pass + + def _populate_profiles(self): + names = ["(None)"] + sorted(self.profiles.keys()) + self.profile_cb["values"] = names + self.profile_cb.set(names[0]) + + def _open_manage_profiles(self): + dlg = ProfileManagerDialog(self.root, self._profiles_path, self.profiles) + self.root.wait_window(dlg) + # reload and refresh + self.profiles = self._load_profiles() + self._populate_profiles() + + def _close_profile_panel(self): + try: + if hasattr(self, "profile_panel") and self.profile_panel and self.profile_panel.winfo_exists(): + self.profile_panel.destroy() + except Exception: + pass + # reload and refresh + self.profiles = self._load_profiles() + self._populate_profiles() + + def _on_profile_change(self): + sel = self.profile_var.get() + if not sel or sel == "(None)": + # enable editing of entries + try: + self.src_entry.config(state="normal") + self.dst_entry.config(state="normal") + self.src_browse.config(state="normal") + self.dst_browse.config(state="normal") + except Exception: + pass + self.current_ignore_exts = [] + return + profile = self.profiles.get(sel) + if not profile: + return + # set entries and make readonly + self.src_path_var.set(profile.get("source", "")) + self.dst_path_var.set(profile.get("destination", "")) + # load ignore extensions for comparisons + ign = profile.get("ignore_extensions", []) + if isinstance(ign, (list, tuple)): + self.current_ignore_exts = ign + elif isinstance(ign, str) and ign: + # comma-separated string + self.current_ignore_exts = [e if e.startswith('.') else '.' + e for e in (x.strip() for x in ign.split(',')) if e] + else: + self.current_ignore_exts = [] + try: + self.src_entry.config(state="readonly") + self.dst_entry.config(state="readonly") + self.src_browse.config(state="disabled") + self.dst_browse.config(state="disabled") + except Exception: + pass + def _run_comparison(self) -> None: """ Executes comparison and populates the treeview. @@ -88,7 +259,7 @@ class MainWindow: if not src or not dst: messagebox.showwarning("Warning", "Please select both source and destination folders.") return - self.comparer = CodeComparer(src, dst) + self.comparer = CodeComparer(src, dst, ignore_extensions=self.current_ignore_exts) added, modified, deleted = self.comparer.compare() for item in self.tree.get_children(): self.tree.delete(item) @@ -153,4 +324,32 @@ class MainWindow: if confirm: commit_msg = self.sync_manager.apply_package(package_path, dst) messagebox.showinfo("Import Complete", f"Package applied successfully.\n\nCommit Message:\n{commit_msg}") - self._run_comparison() \ No newline at end of file + self._run_comparison() + + + + def _on_closing(self) -> None: + """Clean shutdown: stop resource monitor and logging, then exit.""" + try: + if self._resource_monitor: + try: + self._resource_monitor.stop() + except Exception: + pass + except Exception: + pass + + try: + if self.logger_sys: + try: + self.logger_sys.shutdown() + except Exception: + pass + except Exception: + pass + + try: + self.root.destroy() + except Exception: + # fallback + os._exit(0) \ No newline at end of file diff --git a/codebridge/gui/profile_dialog.py b/codebridge/gui/profile_dialog.py new file mode 100644 index 0000000..e5771c9 --- /dev/null +++ b/codebridge/gui/profile_dialog.py @@ -0,0 +1,223 @@ +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +import os +import json +from typing import Dict, Any + + +class ProfileManagerFrame(ttk.Frame): + """Embeddable frame to create/edit/delete folder profiles. + + Can be placed inside the main window (centered) or inside a Toplevel. + """ + + def __init__(self, parent, profiles_path: str, profiles: Dict[str, Any]): + super().__init__(parent, padding=8, relief="raised") + self.parent = parent + self.profiles_path = profiles_path + self.profiles = profiles.copy() + self._setup_ui() + self._populate_list() + + def _setup_ui(self): + left = ttk.Frame(self, padding=8) + left.grid(row=0, column=0, sticky="ns") + ttk.Label(left, text="Profiles").pack(anchor="w") + self.listbox = tk.Listbox(left, width=30, height=20) + self.listbox.pack(fill="y", expand=True) + self.listbox.bind("<>", self._on_select) + + right = ttk.Frame(self, padding=8) + right.grid(row=0, column=1, sticky="nsew") + + # Name (required) + ttk.Label(right, text="Name (required)").grid(row=0, column=0, sticky="w") + self.name_var = tk.StringVar() + self.name_entry = ttk.Entry(right, textvariable=self.name_var) + self.name_entry.grid(row=0, column=1, sticky="ew", pady=2) + + # Description (optional) + ttk.Label(right, text="Description").grid(row=1, column=0, sticky="w") + self.desc_var = tk.StringVar() + self.desc_entry = ttk.Entry(right, textvariable=self.desc_var) + self.desc_entry.grid(row=1, column=1, sticky="ew", pady=2) + + # Source folder + ttk.Label(right, text="Source folder").grid(row=2, column=0, sticky="w") + self.src_var = tk.StringVar() + src_row = ttk.Frame(right) + src_row.grid(row=2, column=1, sticky="ew", pady=2) + self.src_entry = ttk.Entry(src_row, textvariable=self.src_var) + self.src_entry.pack(side="left", fill="x", expand=True) + ttk.Button(src_row, text="📁 Browse", command=self._browse_src).pack(side="left", padx=4) + + # Destination folder + ttk.Label(right, text="Destination folder").grid(row=3, column=0, sticky="w") + self.dst_var = tk.StringVar() + dst_row = ttk.Frame(right) + dst_row.grid(row=3, column=1, sticky="ew", pady=2) + self.dst_entry = ttk.Entry(dst_row, textvariable=self.dst_var) + self.dst_entry.pack(side="left", fill="x", expand=True) + ttk.Button(dst_row, text="📁 Browse", command=self._browse_dst).pack(side="left", padx=4) + + # Ignore extensions (multi-line text for more space) + ttk.Label(right, text="Ignore extensions (comma-separated)").grid(row=4, column=0, sticky="nw") + ignore_row = ttk.Frame(right) + ignore_row.grid(row=4, column=1, sticky="ew", pady=2) + self.ignore_text = tk.Text(ignore_row, height=3, width=60) + self.ignore_text.pack(side="left", fill="both", expand=True) + + # Buttons + btn_row = ttk.Frame(right) + btn_row.grid(row=5, column=0, columnspan=2, pady=12, sticky="ew") + ttk.Button(btn_row, text="➕ New", command=self._new).pack(side="left", padx=4) + ttk.Button(btn_row, text="💾 Save", command=self._save).pack(side="left", padx=4) + ttk.Button(btn_row, text="🗑️ Delete", command=self._delete).pack(side="left", padx=4) + ttk.Button(btn_row, text="Insert common", command=self._insert_common_extensions).pack(side="left", padx=4) + ttk.Button(btn_row, text="✖ Close", command=self._on_close_requested).pack(side="right", padx=4) + + right.columnconfigure(1, weight=1) + self.columnconfigure(1, weight=1) + + # --- Internal helpers --- + def _populate_list(self): + self.listbox.delete(0, "end") + for name in sorted(self.profiles.keys()): + self.listbox.insert("end", name) + + def _on_select(self, _evt=None): + sel = self.listbox.curselection() + if not sel: + return + name = self.listbox.get(sel[0]) + p = self.profiles.get(name, {}) + self.name_var.set(name) + self.desc_var.set(p.get("description", "")) + self.src_var.set(p.get("source", "")) + self.dst_var.set(p.get("destination", "")) + # load ignore extensions + ign = p.get("ignore_extensions") + if isinstance(ign, (list, tuple)): + self.ignore_text.delete("1.0", "end") + self.ignore_text.insert("1.0", ",".join([e.lstrip('.') for e in ign])) + else: + self.ignore_text.delete("1.0", "end") + self.ignore_text.insert("1.0", ign or "") + + def _browse_src(self): + parent = self.winfo_toplevel() + d = filedialog.askdirectory(parent=parent) + if d: + self.src_var.set(d) + + def _browse_dst(self): + parent = self.winfo_toplevel() + d = filedialog.askdirectory(parent=parent) + if d: + self.dst_var.set(d) + + def _new(self): + self.listbox.selection_clear(0, "end") + self.name_var.set("") + self.desc_var.set("") + self.src_var.set("") + self.dst_var.set("") + self.ignore_text.delete("1.0", "end") + self.name_entry.focus() + + def _save(self): + name = self.name_var.get().strip() + if not name: + messagebox.showwarning("Validation", "Profile name is required.", parent=self.winfo_toplevel()) + return + profile = { + "description": self.desc_var.get().strip(), + "source": self.src_var.get().strip(), + "destination": self.dst_var.get().strip() + } + # parse ignore extensions from comma-separated input + raw = self.ignore_text.get("1.0", "end").strip() + if raw: + parts = [p.strip() for p in raw.split(",") if p.strip()] + # normalize to start with dot + norm = [] + for p in parts: + if not p.startswith("."): + p = "." + p + norm.append(p) + profile["ignore_extensions"] = norm + else: + profile["ignore_extensions"] = [] + self.profiles[name] = profile + try: + with open(self.profiles_path, "w", encoding="utf-8") as f: + json.dump(self.profiles, f, indent=2) + except Exception as e: + messagebox.showerror("Error", f"Failed to save profiles: {e}", parent=self.winfo_toplevel()) + return + self._populate_list() + messagebox.showinfo("Saved", "Profile saved.", parent=self.winfo_toplevel()) + + def _delete(self): + name = self.name_var.get().strip() + if not name: + return + if name not in self.profiles: + messagebox.showwarning("Not found", "Profile not found.", parent=self.winfo_toplevel()) + return + if not messagebox.askyesno("Confirm", f"Delete profile '{name}'?", parent=self.winfo_toplevel()): + return + del self.profiles[name] + try: + with open(self.profiles_path, "w", encoding="utf-8") as f: + json.dump(self.profiles, f, indent=2) + except Exception as e: + messagebox.showerror("Error", f"Failed to save profiles: {e}", parent=self.winfo_toplevel()) + return + self._populate_list() + self._new() + + def _insert_common_extensions(self): + # common list without dots (entry convenience) + common = ",".join([ + "o", "d", "obj", "class", "pyc", "pyo", "log", "tmp", "swp", "DS_Store", + "exe", "a", "mk", "bak" + ]) + self.ignore_text.delete("1.0", "end") + self.ignore_text.insert("1.0", common) + + def _on_close_requested(self): + # emit a virtual event so parent can handle closing/hiding + try: + self.event_generate("<>") + except Exception: + pass + + +class ProfileManagerDialog(tk.Toplevel): + """Backward-compatible Toplevel wrapper using the embeddable frame.""" + + def __init__(self, parent, profiles_path: str, profiles: Dict[str, Any]): + super().__init__(parent) + self.parent = parent + self.title("Manage Profiles") + desired_w, desired_h = 760, 420 + self.geometry(f"{desired_w}x{desired_h}") + frame = ProfileManagerFrame(self, profiles_path, profiles) + frame.pack(fill="both", expand=True) + frame.bind("<>", lambda e: self.destroy()) + self.transient(parent) + # center over parent window + try: + self.update_idletasks() + px = parent.winfo_rootx() + py = parent.winfo_rooty() + pw = parent.winfo_width() + ph = parent.winfo_height() + x = px + max(0, (pw - desired_w) // 2) + y = py + max(0, (ph - desired_h) // 2) + self.geometry(f"{desired_w}x{desired_h}+{x}+{y}") + except Exception: + # fallback: keep default geometry + pass + self.grab_set() diff --git a/profiles.json b/profiles.json new file mode 100644 index 0000000..bcd6902 --- /dev/null +++ b/profiles.json @@ -0,0 +1,47 @@ +{ + "DevEnv": { + "description": "DevEnv Grifo E", + "source": "//tsclient/D/__BACKUP/GrifoE/GRIFO-E_svn/DevEnv", + "destination": "C:/src/GRIFO-E - Copia/DevEnv", + "ignore_extensions": [ + ".o", + ".d", + ".obj", + ".class", + ".pyc", + ".pyo", + ".log", + ".tmp", + ".swp", + ".DS_Store", + ".exe", + ".a", + ".mk", + ".bak", + ".defs", + ".txt", + ".pdom" + ] + }, + "REP": { + "description": "REP Grifo E code base", + "source": "C:/src/GRIFO-E - Copia/REP", + "destination": "//tsclient/D/__BACKUP/GrifoE/GRIFO-E_svn/REP", + "ignore_extensions": [ + ".o", + ".d", + ".obj", + ".class", + ".pyc", + ".pyo", + ".log", + ".tmp", + ".swp", + ".DS_Store", + ".exe", + ".a", + ".mk", + ".bak" + ] + } +} \ No newline at end of file