803 lines
39 KiB
Python
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 |