# projectutility/gui/config_window.py import tkinter as tk from tkinter import ttk, messagebox import logging import os import threading import dataclasses # Needed for dataclasses.replace from typing import Dict, Any, Optional, Callable, List # --- Import Core Components --- # Use absolute imports from the 'projectutility' package CORE_AVAILABLE = True GIT_ENABLED = False try: from projectutility.core.registry_models import ToolRegistryEntry # Try importing git_manager to determine availability and get constants from projectutility.core import git_manager GIT_ENABLED = git_manager.GITPYTHON_AVAILABLE # Import Git status constants (use real ones if Git is enabled) if GIT_ENABLED: from projectutility.core.git_manager import ( GIT_STATUS_UP_TO_DATE, GIT_STATUS_BEHIND, GIT_STATUS_AHEAD, GIT_STATUS_DIVERGED, GIT_STATUS_NOT_CLONED, GIT_STATUS_ERROR, GIT_STATUS_CHECKING, GIT_STATUS_UPDATING, GIT_STATUS_GITPYTHON_MISSING, ) logging.getLogger(__name__).debug("Successfully imported core models and git_manager constants.") else: # GitPython not found via git_manager, define dummy constants logging.getLogger(__name__).warning("GitPython not available via git_manager. Defining dummy Git status constants.") GIT_STATUS_UP_TO_DATE = "Up-to-date (N/A)" GIT_STATUS_BEHIND = "Behind (N/A)" GIT_STATUS_AHEAD = "Ahead (N/A)" GIT_STATUS_DIVERGED = "Diverged (N/A)" GIT_STATUS_NOT_CLONED = "Not Cloned (N/A)" GIT_STATUS_ERROR = "Error" GIT_STATUS_CHECKING = "Checking... (N/A)" GIT_STATUS_UPDATING = "Updating... (N/A)" GIT_STATUS_GITPYTHON_MISSING = "GitPython Missing" # This one is real except ImportError as e: # Failed to import core components, this window will be severely limited logging.getLogger(__name__).critical( f"Failed to import core modules (registry_models/git_manager): {e}. " f"ConfigWindow functionality will be limited.", exc_info=True ) CORE_AVAILABLE = False GIT_ENABLED = False # Define dummy model and constants if imports fail @dataclasses.dataclass class ToolRegistryEntry: id: str = "dummy" display_name: str = "Dummy (Import Failed)" type: str = "local" description: Optional[str] = None run_command: List[str] = dataclasses.field(default_factory=list) has_gui: bool = False enabled: bool = False git_url: Optional[str] = None git_ref: str = "main" parameters: Optional[List[Dict[str, Any]]] = None parameters_definition_file: Optional[str] = None GIT_STATUS_UP_TO_DATE = "?" GIT_STATUS_BEHIND = "?" GIT_STATUS_AHEAD = "?" GIT_STATUS_DIVERGED = "?" GIT_STATUS_NOT_CLONED = "?" GIT_STATUS_ERROR = "Error" GIT_STATUS_CHECKING = "?" GIT_STATUS_UPDATING = "?" GIT_STATUS_GITPYTHON_MISSING = "N/A" class ConfigWindow(tk.Toplevel): """ A modal dialog window for viewing and managing the configuration of a single tool entry from the registry. Allows editing common fields and provides controls for Git-based tools (status check, update trigger). """ def __init__( self, parent: tk.Tk, tool_id: str, tool_config: ToolRegistryEntry, git_status_func: Callable[[str], Dict[str, Any]], # Func from MainWindow to get Git status update_func: Callable[[str], None], # Func from MainWindow to trigger update thread save_registry_func: Callable[[List[ToolRegistryEntry]], bool], # Func to save registry get_full_registry_func: Callable[[], Optional[List[ToolRegistryEntry]]], # Func to get current registry ) -> None: """ Initializes the tool configuration window. Args: parent: The parent Tkinter window (usually the MainWindow root). tool_id: The unique ID of the tool being configured. tool_config: The ToolRegistryEntry object representing the current configuration of the tool to be displayed/edited. git_status_func: A callable (provided by MainWindow) that takes the tool_id and returns a dictionary with Git status information. update_func: A callable (provided by MainWindow) that takes the tool_id to trigger the background update process for that tool. save_registry_func: A callable (likely registry_manager.save_registry) that takes the full, modified list of registry entries and attempts to save it, returning True on success. get_full_registry_func: A callable (likely registry_manager.load_registry) that returns the current list of all ToolRegistryEntry objects, or None on failure. """ super().__init__(parent) self.parent = parent self.tool_id = tool_id # Keep a reference to the original config to compare changes if needed, # though currently we fetch the whole registry on save. self.original_config: ToolRegistryEntry = tool_config self.get_git_status = git_status_func self.trigger_update = update_func self.save_registry = save_registry_func self.get_full_registry = get_full_registry_func self.logger = logging.getLogger(f"{__name__}.{tool_id}") # Logger specific to this tool's config window self.logger.info(f"Opening configuration window for tool: {self.tool_id}") if not CORE_AVAILABLE: self.logger.error("Core modules not available. Configuration options will be limited or non-functional.") # Optionally show a message box here? # --- Window Setup --- self.title(f"Configure Tool: {self.original_config.display_name}") # Set initial size (adjust as needed) self.geometry("650x580") self.resizable(False, False) # Prevent resizing # Make the window modal (grab focus, block interaction with parent) self.transient(parent) # Associate with parent (e.g., stays on top) self.grab_set() # Direct all events to this window # self.focus_set() # Set focus to this window (usually automatic with grab_set) # --- Tkinter Variables for Editable Fields --- # Initialize variables with values from the passed tool_config self.display_name_var = tk.StringVar(value=self.original_config.display_name) self.description_var = tk.StringVar(value=self.original_config.description or "") # Use empty string if None self.enabled_var = tk.BooleanVar(value=self.original_config.enabled) # Git-specific editable field (only relevant if type is 'git') self.git_ref_var = tk.StringVar(value=self.original_config.git_ref or "main") # Default to 'main' if somehow None # --- Tkinter Variables for Displaying Git Status (Read-Only) --- self.git_status_var = tk.StringVar(value="-" if GIT_ENABLED else GIT_STATUS_GITPYTHON_MISSING) self.git_local_hash_var = tk.StringVar(value="-") self.git_remote_hash_var = tk.StringVar(value="-") self.git_local_ref_var = tk.StringVar(value="-") # --- Build UI Widgets --- self._create_widgets() # --- Load Initial Git Status (if applicable) --- if self.original_config.type == "git" and GIT_ENABLED: self.logger.debug("Requesting initial Git status refresh.") self._refresh_git_status() elif not GIT_ENABLED and self.original_config.type == "git": self.logger.warning("Cannot display Git status: GitPython library is missing.") # Status var already set to GIT_STATUS_GITPYTHON_MISSING # --- Final Window Adjustments --- # Intercept window close button (top-right 'X') self.protocol("WM_DELETE_WINDOW", self._on_close) # Center the window relative to the parent self.update_idletasks() # Ensure window size is calculated self._center_window() # Wait for the window to be visible and events processed before releasing grab # self.wait_window(self) # This would block until destroyed - use grab_set instead. def _center_window(self) -> None: """Calculates and sets the window position to center it over the parent.""" try: self.update_idletasks() # Ensure winfo_* methods return correct values parent_x = self.parent.winfo_rootx() parent_y = self.parent.winfo_rooty() parent_width = self.parent.winfo_width() parent_height = self.parent.winfo_height() window_width = self.winfo_width() window_height = self.winfo_height() position_x = parent_x + (parent_width // 2) - (window_width // 2) position_y = parent_y + (parent_height // 2) - (window_height // 2) self.geometry(f"+{position_x}+{position_y}") self.logger.debug(f"Centered window at +{position_x}+{position_y}") except Exception as e: self.logger.warning(f"Could not center window: {e}") # Window will appear at default location def _create_widgets(self) -> None: """Creates and arranges all the widgets within the configuration window.""" # Main container frame with padding main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) # Configure grid columns: Label column (0) fixed, Value column (1) expands main_frame.columnconfigure(0, weight=0) main_frame.columnconfigure(1, weight=1) row_idx = 0 # Keep track of the current grid row # --- Basic Info Section (Partially Editable) --- # Tool ID (Read-only) ttk.Label(main_frame, text="Tool ID:", font="-weight bold").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) ttk.Label(main_frame, text=self.tool_id).grid(row=row_idx, column=1, sticky="nw", padx=5, pady=3) row_idx += 1 # Display Name (Editable) ttk.Label(main_frame, text="Display Name:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) ttk.Entry(main_frame, textvariable=self.display_name_var, width=60).grid(row=row_idx, column=1, sticky="ew", padx=5, pady=3) row_idx += 1 # Description (Editable) ttk.Label(main_frame, text="Description:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) # Use a Text widget for potentially longer descriptions? For now, Entry. # Consider increasing width if needed. Max width limited by column 1 expansion. ttk.Entry(main_frame, textvariable=self.description_var, width=60).grid(row=row_idx, column=1, sticky="ew", padx=5, pady=3) # If using Text widget: # desc_text = tk.Text(main_frame, height=3, width=50, wrap=tk.WORD) # desc_text.grid(row=row_idx, column=1, sticky="ew", padx=5, pady=3) # desc_text.insert(tk.END, self.description_var.get()) # # Need to retrieve value from Text widget on save: self.description_var.set(desc_text.get("1.0", tk.END).strip()) row_idx += 1 # Enabled Checkbox (Editable) ttk.Label(main_frame, text="Enabled:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) ttk.Checkbutton(main_frame, variable=self.enabled_var, style="TCheckbutton").grid(row=row_idx, column=1, sticky="w", padx=5, pady=3) row_idx += 1 # Separator before type-specific section ttk.Separator(main_frame, orient="horizontal").grid(row=row_idx, column=0, columnspan=2, sticky="ew", pady=10) row_idx += 1 # --- Type-Specific Configuration Section --- if self.original_config.type == "git": self._create_git_config_widgets(main_frame, row_idx) elif self.original_config.type == "local": self._create_local_config_widgets(main_frame, row_idx) else: # Handle unknown/unsupported tool types ttk.Label( main_frame, text=f"Configuration for tool type '{self.original_config.type}' is not supported.", foreground="orange", font="-style italic" ).grid(row=row_idx, column=0, columnspan=2, sticky="w", padx=5, pady=5) # --- Action Buttons Frame --- # Place buttons at the bottom, aligned to the right button_frame = ttk.Frame(self, padding=(10, 5, 10, 10)) # Padding: left, top, right, bottom button_frame.pack(fill=tk.X, side=tk.BOTTOM) # Make column 1 expand, pushing buttons (in col 1, 2) to the right button_frame.columnconfigure(0, weight=1) self.save_button = ttk.Button( button_frame, text="Save Changes", command=self._save_changes, style="Accent.TButton" # Optional style for emphasis ) self.save_button.grid(row=0, column=1, padx=5) self.close_button = ttk.Button( button_frame, text="Close", command=self._on_close # Use the same close handler as WM_DELETE_WINDOW ) self.close_button.grid(row=0, column=2, padx=5) # Set initial state for save button (maybe disable until changes are made?) # self.save_button.config(state=tk.DISABLED) # Requires change tracking logic self.logger.debug("UI widgets created successfully.") def _create_git_config_widgets(self, parent_frame: ttk.Frame, start_row: int) -> None: """Creates the widgets specific to Git tool configuration.""" self.logger.debug("Creating Git-specific configuration widgets.") row_idx = start_row # --- Git Repository Details (Read-Only and Editable) --- # Git URL (Read-Only) ttk.Label(parent_frame, text="Git URL:", font="-weight bold").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) # Wrap long URLs url_label = ttk.Label(parent_frame, text=self.original_config.git_url or "N/A", wraplength=450, anchor="w") url_label.grid(row=row_idx, column=1, sticky="ew", padx=5, pady=3) row_idx += 1 # Git Ref (Branch/Tag/Commit) (Editable) ttk.Label(parent_frame, text="Git Ref:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) ttk.Entry(parent_frame, textvariable=self.git_ref_var, width=40).grid(row=row_idx, column=1, sticky="ew", padx=5, pady=3) row_idx += 1 # --- Other Registry Info (Read-Only in this window) --- # Run Command (Read-Only) ttk.Label(parent_frame, text="Run Command:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) run_cmd_text = " ".join(self.original_config.run_command) if self.original_config.run_command else "N/A" ttk.Label(parent_frame, text=run_cmd_text, wraplength=450).grid(row=row_idx, column=1, sticky="ew", padx=5, pady=3) row_idx += 1 # Has GUI (Read-Only) ttk.Label(parent_frame, text="Has GUI:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) ttk.Label(parent_frame, text=str(self.original_config.has_gui)).grid(row=row_idx, column=1, sticky="w", padx=5, pady=3) row_idx += 1 # Parameter Definition File (Read-Only) ttk.Label(parent_frame, text="Params File:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) params_file_text = self.original_config.parameters_definition_file or "None" ttk.Label(parent_frame, text=params_file_text).grid(row=row_idx, column=1, sticky="w", padx=5, pady=3) row_idx += 1 # --- Git Status Section --- # Separator before status section ttk.Separator(parent_frame, orient="horizontal").grid(row=row_idx, column=0, columnspan=2, sticky="ew", pady=10) row_idx += 1 # LabelFrame for Git Status git_status_frame = ttk.LabelFrame(parent_frame, text="Git Repository Status", padding="10") git_status_frame.grid(row=row_idx, column=0, columnspan=2, sticky="ew", padx=5, pady=5) # Configure status frame columns git_status_frame.columnconfigure(0, weight=0) git_status_frame.columnconfigure(1, weight=1) # Allow value labels to expand s_row = 0 # Row index within the status frame # Status Label ttk.Label(git_status_frame, text="Status:").grid(row=s_row, column=0, sticky="nw", padx=5, pady=2) status_value_label = ttk.Label(git_status_frame, textvariable=self.git_status_var, font="-weight bold", wraplength=400) status_value_label.grid(row=s_row, column=1, sticky="ew", padx=5, pady=2) s_row += 1 # Local Ref Label ttk.Label(git_status_frame, text="Local Ref:").grid(row=s_row, column=0, sticky="nw", padx=5, pady=2) ttk.Label(git_status_frame, textvariable=self.git_local_ref_var).grid(row=s_row, column=1, sticky="w", padx=5, pady=2) s_row += 1 # Local Hash Label ttk.Label(git_status_frame, text="Local Hash:").grid(row=s_row, column=0, sticky="nw", padx=5, pady=2) ttk.Label(git_status_frame, textvariable=self.git_local_hash_var).grid(row=s_row, column=1, sticky="w", padx=5, pady=2) s_row += 1 # Remote Ref Label (uses the *original* configured git_ref) ttk.Label(git_status_frame, text="Remote Ref:").grid(row=s_row, column=0, sticky="nw", padx=5, pady=2) ttk.Label(git_status_frame, text=f"origin/{self.original_config.git_ref}").grid(row=s_row, column=1, sticky="w", padx=5, pady=2) s_row += 1 # Remote Hash Label ttk.Label(git_status_frame, text="Remote Hash:").grid(row=s_row, column=0, sticky="nw", padx=5, pady=2) ttk.Label(git_status_frame, textvariable=self.git_remote_hash_var).grid(row=s_row, column=1, sticky="w", padx=5, pady=2) s_row += 1 # --- Git Action Buttons --- git_action_frame = ttk.Frame(git_status_frame) # Place below the status labels, span columns if needed, add top padding git_action_frame.grid(row=s_row, column=0, columnspan=2, sticky="ew", pady=(10, 0)) # Align buttons within the action frame (e.g., to the right) git_action_frame.columnconfigure(0, weight=1) # Push buttons right self.refresh_status_button = ttk.Button( git_action_frame, text="Refresh Status", command=self._refresh_git_status, state=tk.NORMAL if GIT_ENABLED else tk.DISABLED # Disable if GitPython missing ) self.refresh_status_button.grid(row=0, column=1, padx=5) self.update_button = ttk.Button( git_action_frame, text="Update This Tool", command=self._trigger_single_update, state=tk.DISABLED # Initially disabled until status check confirms update needed ) self.update_button.grid(row=0, column=2, padx=5) # If GitPython is missing, add a message if not GIT_ENABLED: ttk.Label(git_status_frame, text="GitPython library not installed.", foreground="red").grid(row=s_row+1, column=0, columnspan=2, sticky="w", padx=5, pady=(5,0)) def _create_local_config_widgets(self, parent_frame: ttk.Frame, start_row: int) -> None: """Creates the widgets specific to Local tool configuration.""" self.logger.debug("Creating Local-specific configuration widgets.") row_idx = start_row # Run Command (Read-Only) ttk.Label(parent_frame, text="Run Command:", font="-weight bold").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) run_cmd_text = " ".join(self.original_config.run_command) if self.original_config.run_command else "N/A" ttk.Label(parent_frame, text=run_cmd_text, wraplength=450).grid(row=row_idx, column=1, sticky="ew", padx=5, pady=3) row_idx += 1 # Has GUI (Read-Only) ttk.Label(parent_frame, text="Has GUI:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) ttk.Label(parent_frame, text=str(self.original_config.has_gui)).grid(row=row_idx, column=1, sticky="w", padx=5, pady=3) row_idx += 1 # Parameters Source Info (Read-Only) ttk.Label(parent_frame, text="Parameters:", font="-weight bold").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=3) # Check if inline parameters are defined in the registry entry params_text = "Defined inline in registry." if self.original_config.parameters else "None defined." ttk.Label(parent_frame, text=params_text, style="Italic.TLabel").grid(row=row_idx, column=1, sticky="w", padx=5, pady=3) # Note: Local tools don't use parameters_definition_file currently. def _refresh_git_status(self) -> None: """ Requests and updates the display of the Git repository status. Uses the `git_status_func` passed during initialization, executing it in a separate thread to avoid blocking the GUI. Updates UI elements to indicate loading state. """ if self.original_config.type != "git" or not GIT_ENABLED: self.logger.debug("Skipping Git status refresh: Not a Git tool or GitPython unavailable.") return self.logger.info(f"Requesting Git status refresh for tool: {self.tool_id}") # Update UI to show "Checking..." state and disable buttons self.git_status_var.set(GIT_STATUS_CHECKING) self.git_local_hash_var.set("...") self.git_remote_hash_var.set("...") self.git_local_ref_var.set("...") # Disable buttons during check if hasattr(self, 'update_button'): # Check attribute exists self.update_button.config(state=tk.DISABLED) if hasattr(self, 'refresh_status_button'): self.refresh_status_button.config(state=tk.DISABLED) # Force GUI update to show changes immediately self.update_idletasks() # --- Execute status check in a background thread --- # This prevents the GUI from freezing during potentially slow network operations (fetch). thread = threading.Thread( target=self._get_status_background, daemon=True, # Allows program to exit even if thread is running name=f"{self.tool_id}_git_status_check" ) thread.start() self.logger.debug("Git status check thread started.") def _get_status_background(self) -> None: """ Background task executed by the status refresh thread. Calls the `get_git_status` function (provided by MainWindow) and schedules the `_update_status_display` method to run on the main GUI thread with the results. """ self.logger.debug("Background thread: Calling get_git_status function.") status_info: Dict[str, Any] = { "status": GIT_STATUS_ERROR, "message": "Failed to get status from background task.", # Ensure default keys exist "local_hash": None, "remote_hash": None, "local_ref": None, "remote_ref": None, } # Default error state try: # Call the function passed from MainWindow (which calls git_manager) status_info = self.get_git_status(self.tool_id) self.logger.debug(f"Background thread: Received status_info: {status_info}") except Exception as e: # Catch any error occurring within the passed get_git_status function self.logger.exception( f"Error occurred while calling the provided get_git_status function " f"for tool '{self.tool_id}' in background thread." ) status_info["status"] = GIT_STATUS_ERROR # Ensure status reflects error status_info["message"] = f"Error executing status function: {e}" # --- Schedule GUI update on the main thread --- # Use `self.after(0, ...)` to safely queue the GUI update function # to be executed by the Tkinter event loop from the main thread. # Pass the retrieved status_info dictionary to the update function. try: self.after(0, self._update_status_display, status_info) self.logger.debug("Scheduled _update_status_display on main thread.") except tk.TclError: self.logger.warning("Could not schedule GUI update (window likely closing).") except Exception as e: self.logger.exception("Unexpected error scheduling GUI update from background thread.") def _update_status_display(self, status_info: Dict[str, Any]) -> None: """ Updates the GUI widgets with the Git status information received. This method is executed on the main GUI thread via `self.after()`. Args: status_info: The dictionary containing Git status details returned by the `get_git_status` function. """ if not self.winfo_exists(): # Check if window still exists self.logger.warning("Cannot update status display: ConfigWindow no longer exists.") return self.logger.info(f"Updating Git status display for '{self.tool_id}': {status_info}") # Extract status details safely using .get() with defaults status = status_info.get("status", GIT_STATUS_ERROR) message = status_info.get("message", "No message provided.") local_hash = status_info.get("local_hash") remote_hash = status_info.get("remote_hash") local_ref = status_info.get("local_ref") # Format values for display status_display = f"{status} - {message}" local_hash_display = str(local_hash)[:12] if local_hash else "-" # Show short hash or "-" remote_hash_display = str(remote_hash)[:12] if remote_hash else "-" local_ref_display = str(local_ref) if local_ref else "-" # Update Tkinter StringVars bound to the labels self.git_status_var.set(status_display) self.git_local_hash_var.set(local_hash_display) self.git_remote_hash_var.set(remote_hash_display) self.git_local_ref_var.set(local_ref_display) # Enable/disable action buttons based on the status can_update = status in [ GIT_STATUS_BEHIND, GIT_STATUS_DIVERGED, GIT_STATUS_NOT_CLONED, # Allow update attempt even if not cloned (it will clone first) ] update_button_state = tk.NORMAL if GIT_ENABLED and can_update else tk.DISABLED refresh_button_state = tk.NORMAL if GIT_ENABLED else tk.DISABLED if hasattr(self, 'update_button'): self.update_button.config(state=update_button_state) if hasattr(self, 'refresh_status_button'): self.refresh_status_button.config(state=refresh_button_state) self.logger.debug(f"Git status display updated. Update possible: {can_update}, Git enabled: {GIT_ENABLED}") def _trigger_single_update(self) -> None: """ Initiates the update process for this specific tool. Calls the `update_func` (provided by MainWindow) which is responsible for starting the actual update logic (likely in a background thread). Updates UI elements to show "Updating..." state. """ if not GIT_ENABLED: messagebox.showerror("Git Error", GIT_STATUS_GITPYTHON_MISSING, parent=self) return # Get the target Git reference from the editable field target_ref = self.git_ref_var.get() if not target_ref: messagebox.showerror("Validation Error", "Git Ref cannot be empty.", parent=self) return # Confirm with the user before starting the update # Include the target ref in the confirmation message confirm_msg = ( f"This will attempt to update the tool '{self.original_config.display_name}' " f"to match the remote reference '{target_ref}'.\n\n" f"Proceed with update?" ) if messagebox.askyesno("Confirm Update", confirm_msg, parent=self): self.logger.info(f"User confirmed. Triggering single update for tool '{self.tool_id}' to ref '{target_ref}'.") # --- Update UI to 'Updating...' state --- self.git_status_var.set(GIT_STATUS_UPDATING) # Disable all action buttons during update if hasattr(self, 'update_button'): self.update_button.config(state=tk.DISABLED) if hasattr(self, 'refresh_status_button'): self.refresh_status_button.config(state=tk.DISABLED) if hasattr(self, 'save_button'): # Also disable save during update self.save_button.config(state=tk.DISABLED) self.update_idletasks() # Show changes immediately try: # --- Call the update function passed from MainWindow --- # This function is expected to handle launching the background thread # in MainWindow's context. # Note: We might need to save the changed git_ref *before* triggering # the update if the update process itself relies on the latest registry state. # Current git_manager.update_repository takes ToolRegistryEntry, which should # reflect the desired state (including the potentially changed git_ref). # Let's ensure we save the ref change *first*. # --- Modification: Save ref change before triggering update --- config_changed = False if self.original_config.git_ref != target_ref: self.logger.info(f"Git ref changed from '{self.original_config.git_ref}' to '{target_ref}'. Saving before update.") # We need to save just this change without validating/saving others yet. # Option 1: Modify the original_config temporarily (not ideal if immutable) # Option 2: Pass the target_ref explicitly to the update function? No, update needs registry state. # Option 3: Perform a limited save of just the ref change now. if self._save_specific_field('git_ref', target_ref): # Update the original_config reference in memory as well if save successful self.original_config = dataclasses.replace(self.original_config, git_ref=target_ref) config_changed = True else: # If saving the ref change failed, abort the update. messagebox.showerror("Save Error", "Could not save the updated Git Ref to the registry before updating. Aborting update.", parent=self) self._refresh_git_status() # Refresh status to reset UI if hasattr(self, 'save_button'): self.save_button.config(state=tk.NORMAL) # Re-enable save return # Now, trigger the update using the (potentially updated) config self.trigger_update(self.tool_id) self.logger.debug(f"Update function called for tool {self.tool_id}.") # The status display will be updated later when MainWindow processes # the results from the update thread via the queue. # This window doesn't know directly when the update finishes, # but refreshing the status later will show the result. except Exception as e: self.logger.exception(f"Error occurred while triggering the update for tool '{self.tool_id}'.") messagebox.showerror("Update Error", f"Could not start update:\n{e}", parent=self) # Re-enable buttons on error triggering update self._refresh_git_status() # Refresh status to reset UI if hasattr(self, 'save_button'): self.save_button.config(state=tk.NORMAL) # Re-enable save def _save_changes(self) -> None: """ Gathers changes from the UI fields, validates them, updates the registry list in memory, and calls the save_registry function. """ self.logger.info(f"Attempting to save configuration changes for tool: {self.tool_id}") # --- Gather Current Values from Widgets --- try: new_display_name = self.display_name_var.get() new_description = self.description_var.get() # May be empty string new_enabled = self.enabled_var.get() # Get Git ref only if it's a Git tool, otherwise keep original new_git_ref = self.git_ref_var.get() if self.original_config.type == "git" else self.original_config.git_ref except tk.TclError as e: self.logger.error(f"Error reading value from Tkinter variable: {e}") messagebox.showerror("Input Error", "Could not read input values.", parent=self) return # --- Validate Input --- errors = [] if not new_display_name.strip(): errors.append("Display Name cannot be empty.") if self.original_config.type == "git" and not new_git_ref.strip(): errors.append("Git Ref cannot be empty for Git tools.") # Add more validation rules here if needed if errors: error_message = "Please correct the following errors:\n\n- " + "\n- ".join(errors) messagebox.showerror("Validation Error", error_message, parent=self) return # --- Update Registry Data --- self.logger.debug("Loading current full registry to apply changes...") full_registry: Optional[List[ToolRegistryEntry]] = self.get_full_registry() if full_registry is None: # This means the get_full_registry_func (load_registry) failed messagebox.showerror( "Error", "Could not load the tool registry. Cannot save changes.", parent=self, ) return updated = False for i, entry in enumerate(full_registry): if entry.id == self.tool_id: self.logger.debug(f"Found registry entry for tool '{self.tool_id}' at index {i}.") try: # Create a *new* ToolRegistryEntry object with the updated values # using dataclasses.replace() for immutability benefits if frozen=True was used. # If not frozen, direct attribute assignment on 'entry' would also work, # but 'replace' is cleaner and safer. updated_entry = dataclasses.replace( entry, # Start with the existing entry's data # Apply changes from the UI fields: display_name=new_display_name, description=new_description if new_description else None, # Store None if empty enabled=new_enabled, # Only update git_ref if it's a Git tool git_ref=new_git_ref if entry.type == "git" else entry.git_ref, ) # Replace the old entry with the updated one in the list full_registry[i] = updated_entry updated = True self.logger.info(f"Registry entry for '{self.tool_id}' updated in memory.") break # Stop searching once the entry is found and updated except Exception as e: # Catch errors during dataclasses.replace or list modification self.logger.exception( f"Error creating updated registry entry object for '{self.tool_id}': {e}" ) messagebox.showerror( "Internal Error", f"Failed to prepare updated tool configuration object:\n{e}", parent=self, ) return # Abort save if not updated: # This should not happen if the window was opened correctly messagebox.showerror( "Error", f"Could not find tool '{self.tool_id}' in the loaded registry. " f"Cannot save changes.", parent=self, ) return # --- Save Updated Registry --- self.logger.debug("Calling save_registry function with the modified list...") save_successful = self.save_registry(full_registry) if save_successful: self.logger.info("Registry saved successfully via provided function.") messagebox.showinfo( "Success", "Tool configuration saved successfully.\n\n" "The tool list in the main window will be refreshed.", parent=self, ) # Close this configuration window after successful save self._on_close() # Use the close handler # MainWindow needs to be notified to reload tools. # This is currently handled implicitly because load_registry() will be called # again next time discover_tools() runs. If immediate refresh is needed, # MainWindow would need a specific method called here. else: # The save_registry function itself should have logged the specific error self.logger.error("Failed to save the updated registry file (see previous logs).") messagebox.showerror( "Error Saving Registry", "Failed to save the updated configuration to the registry file.\n" "Please check the application logs for more details.", parent=self, ) # Keep the config window open on save failure? Yes. def _save_specific_field(self, field_name: str, new_value: Any) -> bool: """ Helper to save only a specific field change to the registry. Used internally before triggering an update if the Git ref changed. Args: field_name: The name of the attribute in ToolRegistryEntry to update. new_value: The new value for the field. Returns: True if the specific field was saved successfully, False otherwise. """ self.logger.info(f"Attempting to save specific field '{field_name}' = '{new_value}' for tool '{self.tool_id}'.") full_registry = self.get_full_registry() if full_registry is None: self.logger.error("Could not load registry to save specific field.") return False updated = False for i, entry in enumerate(full_registry): if entry.id == self.tool_id: try: # Use replace to create the updated entry updated_entry = dataclasses.replace(entry, **{field_name: new_value}) full_registry[i] = updated_entry updated = True break except Exception as e: self.logger.exception(f"Error using dataclasses.replace for field '{field_name}': {e}") return False if not updated: self.logger.error(f"Could not find tool '{self.tool_id}' in registry to save specific field.") return False # Save the entire modified registry return self.save_registry(full_registry) def _on_close(self) -> None: """Handles window closing actions (via button or WM_DELETE_WINDOW).""" self.logger.info(f"Closing configuration window for tool: {self.tool_id}") # Perform any necessary cleanup here, though usually just destroying is enough. # Release the grab explicitly before destroying? Optional. # self.grab_release() self.destroy() # Close the Toplevel window