SXXXXXXX_ProjectUtility/projectutility/gui/config_window.py
2025-05-05 14:38:19 +02:00

803 lines
39 KiB
Python

# 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