SXXXXXXX_GitUtility/gui.py
2025-04-07 15:01:25 +02:00

1042 lines
46 KiB
Python

# gui.py
import tkinter as tk
from tkinter import ttk
from tkinter import scrolledtext
from tkinter import filedialog
from tkinter import messagebox
from tkinter import simpledialog
import logging
import os
import re # Ensure re is imported
# Import constant from the central location
from config_manager import DEFAULT_BACKUP_DIR
# --- Tooltip Class Definition ---
class Tooltip:
"""Simple tooltip implementation for Tkinter widgets."""
def __init__(self, widget, text):
"""Initialize tooltip."""
self.widget = widget
self.text = text
self.tooltip_window = None
self.id = None
self.x = 0
self.y = 0
def showtip(self):
"""Display text in a tooltip window."""
self.hidetip() # Hide any existing tooltip first
if not self.widget.winfo_exists():
return # Avoid error if widget destroyed
try:
# Get widget position relative to widget itself
x_rel, y_rel, _, _ = self.widget.bbox("insert")
# Get widget position relative to screen
x_root = self.widget.winfo_rootx()
y_root = self.widget.winfo_rooty()
# Calculate final screen position with offset
x_pos = x_root + x_rel + 25
y_pos = y_root + y_rel + 25
except tk.TclError:
# Fallback position calculation if bbox fails
x_root = self.widget.winfo_rootx()
y_root = self.widget.winfo_rooty()
widget_width = self.widget.winfo_width()
widget_height = self.widget.winfo_height()
x_pos = x_root + widget_width // 2
y_pos = y_root + widget_height + 5
# Create the tooltip window as a Toplevel
self.tooltip_window = tk.Toplevel(self.widget)
tw = self.tooltip_window
# Remove window decorations (border, title bar)
tw.wm_overrideredirect(True)
# Position the window (ensure integer coordinates)
tw.wm_geometry(f"+{int(x_pos)}+{int(y_pos)}")
# Create the label inside the tooltip window
label = tk.Label(
tw,
text=self.text,
justify=tk.LEFT,
background="#ffffe0", # Light yellow background
relief=tk.SOLID,
borderwidth=1,
font=("tahoma", "8", "normal")
)
label.pack(ipadx=1)
def hidetip(self):
"""Hide the tooltip window."""
tw = self.tooltip_window
self.tooltip_window = None
if tw:
try:
# Check if window still exists before destroying
if tw.winfo_exists():
tw.destroy()
except tk.TclError:
# Handle cases where window might already be destroyed
pass
# --- End Tooltip Class ---
# --- Gitignore Editor Window Class ---
class GitignoreEditorWindow(tk.Toplevel):
""" Toplevel window for editing the .gitignore file. """
def __init__(self, master, gitignore_path, logger):
""" Initialize the editor window. """
super().__init__(master)
self.gitignore_path = gitignore_path
self.logger = logger
# Store original content to check for changes on close
self.original_content = ""
# Window Configuration
self.title(f"Edit {os.path.basename(gitignore_path)}")
self.geometry("600x450")
self.minsize(400, 300)
self.grab_set() # Make window modal
self.transient(master) # Keep on top of parent
self.protocol("WM_DELETE_WINDOW", self._on_close) # Handle close button
# Main frame with padding
main_frame = ttk.Frame(self, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
# Configure grid weights for resizing text area
main_frame.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1)
# ScrolledText widget for editing
self.text_editor = scrolledtext.ScrolledText(
main_frame,
wrap=tk.WORD,
font=("Consolas", 10),
undo=True # Enable undo/redo
)
self.text_editor.grid(row=0, column=0, sticky="nsew", pady=(0, 10))
# Frame for buttons at the bottom
button_frame = ttk.Frame(main_frame)
button_frame.grid(row=1, column=0, sticky="ew")
# Configure button frame columns to center buttons
button_frame.columnconfigure(0, weight=1) # Spacer left
button_frame.columnconfigure(3, weight=1) # Spacer right
# Save button
self.save_button = ttk.Button(
button_frame, text="Save and Close", command=self._save_and_close
)
self.save_button.grid(row=0, column=2, padx=5) # Right-center
# Cancel button
self.cancel_button = ttk.Button(
button_frame, text="Cancel", command=self._on_close
)
self.cancel_button.grid(row=0, column=1, padx=5) # Left-center
# Load initial content
self._load_file()
# Center window relative to parent
self._center_window(master)
# Set initial focus
self.text_editor.focus_set()
def _center_window(self, parent):
"""Centers the editor window relative to its parent."""
self.update_idletasks() # Ensure window size is calculated
parent_x = parent.winfo_rootx()
parent_y = parent.winfo_rooty()
parent_width = parent.winfo_width()
parent_height = parent.winfo_height()
win_width = self.winfo_width()
win_height = self.winfo_height()
x_pos = parent_x + (parent_width // 2) - (win_width // 2)
y_pos = parent_y + (parent_height // 2) - (win_height // 2)
# Basic screen boundary check
screen_width = self.winfo_screenwidth()
screen_height = self.winfo_screenheight()
x_pos = max(0, min(x_pos, screen_width - win_width))
y_pos = max(0, min(y_pos, screen_height - win_height))
# Apply position
self.geometry(f"+{int(x_pos)}+{int(y_pos)}")
def _load_file(self):
"""Loads the content of the .gitignore file into the editor."""
self.logger.info(f"Loading content for: {self.gitignore_path}")
try:
content = "" # Default empty content
if os.path.exists(self.gitignore_path):
# Read file content
with open(self.gitignore_path, 'r',
encoding='utf-8', errors='replace') as f:
content = f.read()
self.logger.debug(".gitignore content loaded.")
else:
# File doesn't exist
self.logger.info(f"'{self.gitignore_path}' does not exist.")
# Store original content and update editor
self.original_content = content
self.text_editor.delete("1.0", tk.END)
self.text_editor.insert(tk.END, self.original_content)
self.text_editor.edit_reset() # Reset undo stack
except IOError as e:
self.logger.error(f"Read error: {e}", exc_info=True)
messagebox.showerror("Error Reading", f"Could not read file:\n{e}",
parent=self)
except Exception as e:
self.logger.exception(f"Unexpected load error: {e}")
messagebox.showerror("Error", f"Unexpected load error:\n{e}",
parent=self)
def _save_file(self):
"""Saves the current editor content to the .gitignore file."""
# Get content, normalize whitespace and newline
current_content = self.text_editor.get("1.0", tk.END).rstrip()
if current_content:
current_content += "\n"
# Normalize original content for comparison
normalized_original = self.original_content.rstrip()
if normalized_original:
normalized_original += "\n"
# Check if content actually changed
if current_content == normalized_original:
self.logger.info("No changes detected. Skipping save.")
return True # Success (no action needed)
# Save changes
self.logger.info(f"Saving changes to: {self.gitignore_path}")
try:
# Write content with consistent newline
with open(self.gitignore_path, 'w', encoding='utf-8', newline='\n') as f:
f.write(current_content)
self.logger.info(".gitignore saved successfully.")
# Update baseline and reset undo
self.original_content = current_content
self.text_editor.edit_reset()
return True # Success
except IOError as e:
self.logger.error(f"Write error: {e}", exc_info=True)
messagebox.showerror("Error Saving", f"Could not save file:\n{e}",
parent=self)
return False # Failure
except Exception as e:
self.logger.exception(f"Unexpected save error: {e}")
messagebox.showerror("Error", f"Unexpected save error:\n{e}",
parent=self)
return False
def _save_and_close(self):
"""Saves the file and closes the window if save succeeds."""
if self._save_file():
self.destroy()
def _on_close(self):
"""Handles closing the window (checks for unsaved changes)."""
# Normalize current and original content for comparison
current_content = self.text_editor.get("1.0", tk.END).rstrip()
if current_content: current_content += "\n"
normalized_original = self.original_content.rstrip()
if normalized_original: normalized_original += "\n"
# Check if changes were made
if current_content != normalized_original:
# Ask user about saving
response = messagebox.askyesnocancel(
"Unsaved Changes",
"You have unsaved changes.\nSave before closing?",
parent=self
)
if response is True: # Yes, Save
self._save_and_close() # Attempts save, closes only if successful
elif response is False: # No, Discard
self.logger.warning("Discarding unsaved changes in editor.")
self.destroy()
# else (Cancel): Do nothing, leave window open
else:
# No changes, just close
self.destroy()
# --- End Gitignore Editor Window ---
# --- Create Tag Dialog ---
class CreateTagDialog(simpledialog.Dialog):
""" Dialog to get new tag name and message. """
def __init__(self, parent, title="Create New Tag"):
"""Initialize the dialog."""
self.tag_name_var = tk.StringVar()
self.tag_message_var = tk.StringVar()
self.result = None # Stores (name, message) tuple on success
super().__init__(parent, title=title)
def body(self, master):
"""Create dialog body with input fields."""
name_label = ttk.Label(master, text="Tag Name:")
name_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.name_entry = ttk.Entry(master, textvariable=self.tag_name_var, width=40)
self.name_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
message_label = ttk.Label(master, text="Tag Message:")
message_label.grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.message_entry = ttk.Entry(master, textvariable=self.tag_message_var, width=40)
self.message_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
master.columnconfigure(1, weight=1) # Allow entry fields to expand
return self.name_entry # Set initial focus
def validate(self):
"""Validate the input fields are not empty and name format is valid."""
name = self.tag_name_var.get().strip()
message = self.tag_message_var.get().strip()
if not name:
messagebox.showwarning("Input Error", "Tag name cannot be empty.",
parent=self)
return 0 # Fail validation
if not message:
messagebox.showwarning("Input Error", "Tag message cannot be empty.",
parent=self)
return 0 # Fail validation
# Validate tag name format using regex (ensure 're' is imported)
# Pattern based on git check-ref-format rules
pattern = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(?<!\.)$"
if not re.match(pattern, name):
messagebox.showwarning("Input Error", "Invalid tag name format.",
parent=self)
return 0 # Fail validation
return 1 # Validation successful
def apply(self):
"""Process the data if validation succeeds."""
name = self.tag_name_var.get().strip()
message = self.tag_message_var.get().strip()
# Store the validated result as a tuple
self.result = (name, message)
# --- End Create Tag Dialog ---
# --- Create Branch Dialog ---
class CreateBranchDialog(simpledialog.Dialog):
""" Dialog to get new branch name. """
def __init__(self, parent, title="Create New Branch"):
"""Initialize the dialog."""
self.branch_name_var = tk.StringVar()
self.result = None # Stores branch name on success
super().__init__(parent, title=title)
def body(self, master):
"""Create dialog body with input field."""
branch_label = ttk.Label(master, text="New Branch Name:")
branch_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.name_entry = ttk.Entry(master, textvariable=self.branch_name_var, width=40)
self.name_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
# TODO: Add option for start point (tag/branch)?
master.columnconfigure(1, weight=1) # Allow entry to expand
return self.name_entry # Initial focus
def validate(self):
"""Validate the branch name input."""
name = self.branch_name_var.get().strip()
if not name:
messagebox.showwarning("Input Error", "Branch name cannot be empty.",
parent=self)
return 0 # Fail validation
# Basic Git branch name validation (ensure 're' is imported)
# Avoids spaces, consecutive dots, ending dot/slash, control chars etc.
pattern = r"^(?![./]|.*([./]{2,}|[.]$|[/]$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(?<!\.lock)$"
if not re.match(pattern, name):
messagebox.showwarning("Input Error", "Invalid branch name format.",
parent=self)
return 0 # Fail validation
return 1 # Validation successful
def apply(self):
"""Process the data if validation succeeds."""
# Store the validated branch name
self.result = self.branch_name_var.get().strip()
# --- End Create Branch Dialog ---
class MainFrame(ttk.Frame):
""" The main frame containing all GUI elements. """
GREEN = "#90EE90" # Light Green
RED = "#F08080" # Light Coral
def __init__(self, master, load_profile_settings_cb, browse_folder_cb,
update_svn_status_cb, prepare_svn_for_git_cb, create_git_bundle_cb,
fetch_from_git_bundle_cb, config_manager_instance, profile_sections_list,
add_profile_cb, remove_profile_cb, manual_backup_cb,
open_gitignore_editor_cb, save_profile_cb, manual_commit_cb,
refresh_tags_cb, create_tag_cb, checkout_tag_cb,
refresh_branches_cb, create_branch_cb, switch_branch_cb, delete_branch_cb):
""" Initializes the MainFrame with all necessary callbacks. """
super().__init__(master)
self.master = master
# Store callbacks provided by the controller (GitUtilityApp)
self.load_profile_settings_callback = load_profile_settings_cb
self.browse_folder_callback = browse_folder_cb
self.update_svn_status_callback = update_svn_status_cb
self.prepare_svn_for_git_callback = prepare_svn_for_git_cb
self.create_git_bundle_callback = create_git_bundle_cb
self.fetch_from_git_bundle_callback = fetch_from_git_bundle_cb
self.add_profile_callback = add_profile_cb
self.remove_profile_callback = remove_profile_cb
self.manual_backup_callback = manual_backup_cb
self.open_gitignore_editor_callback = open_gitignore_editor_cb
self.save_profile_callback = save_profile_cb
self.manual_commit_callback = manual_commit_cb
self.refresh_tags_callback = refresh_tags_cb
self.create_tag_callback = create_tag_cb
self.checkout_tag_callback = checkout_tag_cb
self.refresh_branches_callback = refresh_branches_cb
self.create_branch_callback = create_branch_cb
self.switch_branch_callback = switch_branch_cb
self.delete_branch_callback = delete_branch_cb
# Store config manager and initial profiles list
self.config_manager = config_manager_instance
self.initial_profile_sections = profile_sections_list
# Configure style
self.style = ttk.Style()
self.style.theme_use('clam')
# Pack the main frame into the master window
self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=10)
# --- Tkinter Variables for widget states/content ---
self.profile_var = tk.StringVar()
self.autobackup_var = tk.BooleanVar()
self.backup_dir_var = tk.StringVar()
self.autocommit_var = tk.BooleanVar() # For autocommit before bundle
self.commit_message_var = tk.StringVar() # For manual/auto commit message
self.backup_exclude_extensions_var = tk.StringVar()
self.current_branch_var = tk.StringVar(value="<N/A>") # For branch display
# --- Create GUI Sections ---
self._create_profile_frame()
self._create_repo_frame() # Paths and bundle names
self._create_backup_frame() # Backup settings
self._create_commit_management_frame() # Commit settings
self._create_branch_management_frame() # Branch list and actions
self._create_tag_management_frame() # Tag list and actions
self._create_function_frame() # Core Actions (Prepare, Bundle, etc.)
self._create_log_area()
# --- Initial GUI State ---
self._initialize_profile_selection()
self.toggle_backup_dir() # Set initial state of backup dir entry
def _create_profile_frame(self):
"""Creates the frame for profile selection and management."""
self.profile_frame = ttk.LabelFrame(
self, text="Profile Configuration", padding=(10, 5)
)
self.profile_frame.pack(pady=5, fill="x")
# Allow dropdown column to expand horizontally
self.profile_frame.columnconfigure(1, weight=1)
# Profile Label
profile_label = ttk.Label(self.profile_frame, text="Profile:")
profile_label.grid(row=0, column=0, sticky=tk.W, padx=5, pady=5)
# Profile Dropdown (Combobox)
self.profile_dropdown = ttk.Combobox(
self.profile_frame,
textvariable=self.profile_var,
state="readonly",
width=35,
values=self.initial_profile_sections
)
self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5)
# Bind selection change to load settings
self.profile_dropdown.bind(
"<<ComboboxSelected>>",
lambda e: self.load_profile_settings_callback(self.profile_var.get())
)
# Trace variable for programmatic changes
self.profile_var.trace_add(
"write",
lambda *a: self.load_profile_settings_callback(self.profile_var.get())
)
# Save Settings Button
self.save_settings_button = ttk.Button(
self.profile_frame,
text="Save Settings",
command=self.save_profile_callback
)
self.save_settings_button.grid(row=0, column=2, sticky=tk.W,
padx=(5, 2), pady=5)
self.create_tooltip(self.save_settings_button,
"Save current settings for selected profile.")
# Add Profile Button
self.add_profile_button = ttk.Button(
self.profile_frame, text="Add", width=5,
command=self.add_profile_callback
)
self.add_profile_button.grid(row=0, column=3, sticky=tk.W, padx=(2, 0), pady=5)
# Remove Profile Button
self.remove_profile_button = ttk.Button(
self.profile_frame, text="Remove", width=8,
command=self.remove_profile_callback
)
self.remove_profile_button.grid(row=0, column=4, sticky=tk.W, padx=(2, 5), pady=5)
def _create_repo_frame(self):
"""Creates the frame ONLY for repository paths and bundle names."""
self.repo_frame = ttk.LabelFrame(
self, text="Repository & Bundle Paths", padding=(10, 5)
)
self.repo_frame.pack(pady=5, fill="x")
# Define columns for easier layout management
col_label = 0
col_entry = 1
col_button = 2
col_indicator = 3
# Configure entry column to expand horizontally
self.repo_frame.columnconfigure(col_entry, weight=1)
# Row 0: SVN Path Entry and Browse Button + Status Indicator
svn_label = ttk.Label(self.repo_frame, text="SVN Working Copy:")
svn_label.grid(row=0, column=col_label, sticky=tk.W, padx=5, pady=3)
self.svn_path_entry = ttk.Entry(self.repo_frame, width=60)
self.svn_path_entry.grid(row=0, column=col_entry, sticky=tk.EW, padx=5, pady=3)
self.svn_path_entry.bind("<FocusOut>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
self.svn_path_entry.bind("<Return>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
self.svn_path_browse_button = ttk.Button(
self.repo_frame, text="Browse...", width=9,
command=lambda: self.browse_folder_callback(self.svn_path_entry)
)
self.svn_path_browse_button.grid(row=0, column=col_button, sticky=tk.W, padx=(0, 5), pady=3)
self.svn_status_indicator = tk.Label(
self.repo_frame, text="", width=2, height=1, relief=tk.SUNKEN,
background=self.RED, anchor=tk.CENTER
)
self.svn_status_indicator.grid(row=0, column=col_indicator, sticky=tk.E, padx=(0, 5), pady=3)
self.create_tooltip(self.svn_status_indicator, "Git repo status (Green=Ready, Red=Not Ready)")
# Row 1: USB/Bundle Target Path Entry and Browse Button
usb_label = ttk.Label(self.repo_frame, text="Bundle Target Dir:")
usb_label.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=3)
self.usb_path_entry = ttk.Entry(self.repo_frame, width=60)
self.usb_path_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=3)
self.usb_path_browse_button = ttk.Button(
self.repo_frame, text="Browse...", width=9,
command=lambda: self.browse_folder_callback(self.usb_path_entry)
)
self.usb_path_browse_button.grid(row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=3)
# Row 2: Create Bundle Name Entry
create_label = ttk.Label(self.repo_frame, text="Create Bundle Name:")
create_label.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=3)
self.bundle_name_entry = ttk.Entry(self.repo_frame, width=60)
# Span entry across entry and button columns
self.bundle_name_entry.grid(row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3)
# Row 3: Fetch Bundle Name Entry
fetch_label = ttk.Label(self.repo_frame, text="Fetch Bundle Name:")
fetch_label.grid(row=3, column=col_label, sticky=tk.W, padx=5, pady=3)
self.bundle_updated_name_entry = ttk.Entry(self.repo_frame, width=60)
self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3)
def _create_backup_frame(self):
"""Creates the frame for backup configuration."""
self.backup_frame = ttk.LabelFrame(
self, text="Backup Configuration (ZIP)", padding=(10, 5)
)
self.backup_frame.pack(pady=5, fill="x")
# Define column indices
col_label = 0
col_entry = 1
col_button = 2
# Configure entry column to expand
self.backup_frame.columnconfigure(col_entry, weight=1)
# Row 0: Autobackup Checkbox
self.autobackup_checkbox = ttk.Checkbutton(
self.backup_frame, text="Automatic Backup before Create/Fetch",
variable=self.autobackup_var, command=self.toggle_backup_dir
)
self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, sticky=tk.W, padx=5, pady=(5, 0))
# Row 1: Backup Directory Entry and Browse Button
backup_dir_label = ttk.Label(self.backup_frame, text="Backup Directory:")
backup_dir_label.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=5)
self.backup_dir_entry = ttk.Entry(
self.backup_frame, textvariable=self.backup_dir_var,
width=60, state=tk.DISABLED
)
self.backup_dir_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=5)
self.backup_dir_button = ttk.Button(
self.backup_frame, text="Browse...", width=9,
command=self.browse_backup_dir, state=tk.DISABLED
)
self.backup_dir_button.grid(row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=5)
# Row 2: Exclude Extensions Entry
exclude_label = ttk.Label(self.backup_frame, text="Exclude Extensions:")
exclude_label.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=5)
self.backup_exclude_entry = ttk.Entry(
self.backup_frame, textvariable=self.backup_exclude_extensions_var, width=60
)
# Span entry across entry and button columns
self.backup_exclude_entry.grid(row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=5)
self.create_tooltip(self.backup_exclude_entry, "Comma-separated extensions (e.g., .log,.tmp,.bak)")
# --- NEW: Commit Management Frame ---
def _create_commit_management_frame(self):
"""Creates the frame for commit message and related actions."""
self.commit_frame = ttk.LabelFrame(
self, text="Commit Management", padding=(10, 5)
)
self.commit_frame.pack(pady=5, fill="x")
# Configure columns
self.commit_frame.columnconfigure(1, weight=1) # Entry expands
# Row 0: Autocommit Checkbox (relevant for Create Bundle)
self.autocommit_checkbox = ttk.Checkbutton(
self.commit_frame,
text="Autocommit before 'Create Bundle' (uses message below)",
variable=self.autocommit_var,
state=tk.DISABLED # State depends on repo readiness
)
self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, # Span all columns
sticky="w", padx=5, pady=(5, 3))
self.create_tooltip(self.autocommit_checkbox,
"If checked, commit changes using the message before Create Bundle.")
# Row 1: Commit Message Entry + Manual Commit Button
commit_msg_label = ttk.Label(self.commit_frame, text="Commit Message:")
commit_msg_label.grid(row=1, column=0, sticky="w", padx=5, pady=3)
self.commit_message_entry = ttk.Entry(
self.commit_frame,
textvariable=self.commit_message_var,
width=50, # Adjust width as needed
state=tk.DISABLED # State depends on repo readiness
)
self.commit_message_entry.grid(row=1, column=1, sticky="ew", padx=5, pady=3)
self.create_tooltip(self.commit_message_entry,
"Message for manual commit or commit before tagging/bundling.")
# Manual Commit Button
self.commit_button = ttk.Button(
self.commit_frame,
text="Commit Changes",
width=15, # Adjusted width
command=self.manual_commit_callback, # Connect to controller
state=tk.DISABLED # State depends on repo readiness
)
self.commit_button.grid(row=1, column=2, sticky="w", padx=(5, 0), pady=3)
self.create_tooltip(self.commit_button,
"Manually commit staged changes with the provided message.")
# --- NEW: Branch Management Frame ---
def _create_branch_management_frame(self):
"""Creates the frame for branch operations."""
self.branch_frame = ttk.LabelFrame(
self, text="Branch Management", padding=(10, 5)
)
self.branch_frame.pack(pady=5, fill="x")
# Configure grid
self.branch_frame.columnconfigure(1, weight=1) # Listbox expands
self.branch_frame.rowconfigure(2, weight=1) # Listbox expands vertically
# Row 0: Current Branch Display
current_branch_label = ttk.Label(self.branch_frame, text="Current Branch:")
current_branch_label.grid(row=0, column=0, sticky="w", padx=5, pady=3)
self.current_branch_display = ttk.Label(
self.branch_frame,
textvariable=self.current_branch_var, # Use Tkinter variable
font=("Segoe UI", 9, "bold"), # Style for emphasis
relief=tk.SUNKEN, # Sunken appearance
padding=(3, 1) # Internal padding
)
# Span across listbox and button column? Or just listbox column? Let's span 2
self.current_branch_display.grid(row=0, column=1, columnspan=2,
sticky="ew", padx=5, pady=3)
self.create_tooltip(self.current_branch_display,
"The currently active branch or detached HEAD state.")
# Row 1: Listbox Label
branch_list_label = ttk.Label(self.branch_frame, text="Local Branches:")
branch_list_label.grid(row=1, column=0, columnspan=3, # Span columns below
sticky="w", padx=5, pady=(10, 0)) # Add top padding
# Row 2: Listbox + Scrollbar (in a subframe)
branch_list_frame = ttk.Frame(self.branch_frame)
# Span across first 3 columns (label, listbox, spacer?)
branch_list_frame.grid(row=2, column=0, columnspan=3, sticky="nsew",
padx=5, pady=(0, 5))
branch_list_frame.rowconfigure(0, weight=1)
branch_list_frame.columnconfigure(0, weight=1)
self.branch_listbox = tk.Listbox(
branch_list_frame,
height=5, # Initial height
exportselection=False,
selectmode=tk.SINGLE,
font=("Consolas", 9) # Monospaced font for potential alignment
)
self.branch_listbox.grid(row=0, column=0, sticky="nsew")
# Vertical Scrollbar
branch_scrollbar = ttk.Scrollbar(
branch_list_frame,
orient=tk.VERTICAL,
command=self.branch_listbox.yview
)
branch_scrollbar.grid(row=0, column=1, sticky="ns")
self.branch_listbox.config(yscrollcommand=branch_scrollbar.set)
self.create_tooltip(self.branch_listbox,
"Select a branch for actions (Switch, Delete).")
# Row 2, Column 3: Vertical Button Frame for Branch Actions
branch_button_frame = ttk.Frame(self.branch_frame)
branch_button_frame.grid(row=2, column=3, sticky="ns", # Align North-South
padx=(5, 0), pady=(0, 5))
button_width_branch = 18 # Consistent width for vertical buttons
self.refresh_branches_button = ttk.Button(
branch_button_frame, text="Refresh List", width=button_width_branch,
command=self.refresh_branches_callback, state=tk.DISABLED
)
self.refresh_branches_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 3))
self.create_tooltip(self.refresh_branches_button, "Reload branch list.")
self.create_branch_button = ttk.Button(
branch_button_frame, text="Create Branch...", width=button_width_branch,
command=self.create_branch_callback, state=tk.DISABLED
)
self.create_branch_button.pack(side=tk.TOP, fill=tk.X, pady=3)
self.create_tooltip(self.create_branch_button, "Create a new local branch.")
self.switch_branch_button = ttk.Button(
branch_button_frame, text="Switch to Selected", width=button_width_branch,
command=self.switch_branch_callback, state=tk.DISABLED
)
self.switch_branch_button.pack(side=tk.TOP, fill=tk.X, pady=3)
self.create_tooltip(self.switch_branch_button, "Checkout the selected branch.")
self.delete_branch_button = ttk.Button(
branch_button_frame, text="Delete Selected", width=button_width_branch,
command=self.delete_branch_callback, state=tk.DISABLED
)
self.delete_branch_button.pack(side=tk.TOP, fill=tk.X, pady=(3, 0))
self.create_tooltip(self.delete_branch_button,
"Delete the selected local branch (requires confirmation).")
# --- NEW: Tag Management Frame ---
def _create_tag_management_frame(self):
"""Creates the frame for tag operations."""
self.tag_frame = ttk.LabelFrame(
self, text="Tag Management", padding=(10, 5)
)
self.tag_frame.pack(pady=5, fill="x")
# Configure grid columns
# Col 0: Listbox (Expands)
# Col 1: Vertical Button Column (Fixed Width)
self.tag_frame.columnconfigure(0, weight=1)
self.tag_frame.rowconfigure(0, weight=1) # Listbox expands vertically
# Row 0, Column 0: Listbox + Scrollbar Frame
tag_list_frame = ttk.Frame(self.tag_frame)
tag_list_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
tag_list_frame.rowconfigure(0, weight=1)
tag_list_frame.columnconfigure(0, weight=1)
self.tag_listbox = tk.Listbox(
tag_list_frame,
height=6, # Initial rows visible
exportselection=False,
selectmode=tk.SINGLE,
font=("Consolas", 9) # Monospaced font for alignment
)
self.tag_listbox.grid(row=0, column=0, sticky="nsew")
tag_scrollbar = ttk.Scrollbar(
tag_list_frame,
orient=tk.VERTICAL,
command=self.tag_listbox.yview
)
tag_scrollbar.grid(row=0, column=1, sticky="ns")
self.tag_listbox.config(yscrollcommand=tag_scrollbar.set)
self.create_tooltip(self.tag_listbox,
"Tags (newest first) with messages. Select to checkout.")
# Row 0, Column 1: Vertical Button Frame for Tags/Gitignore
tag_button_frame = ttk.Frame(self.tag_frame)
tag_button_frame.grid(row=0, column=1, sticky="ns", # North-South alignment
padx=(5, 0), pady=5)
button_width_tag = 18 # Consistent width for this column
self.refresh_tags_button = ttk.Button(
tag_button_frame, text="Refresh Tags", width=button_width_tag,
command=self.refresh_tags_callback, state=tk.DISABLED
)
self.refresh_tags_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 3))
self.create_tooltip(self.refresh_tags_button, "Reload tag list.")
self.create_tag_button = ttk.Button(
tag_button_frame, text="Create Tag...", width=button_width_tag,
command=self.create_tag_callback, state=tk.DISABLED
)
self.create_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3)
self.create_tooltip(self.create_tag_button,
"Commit changes (requires message) and create new tag.")
self.checkout_tag_button = ttk.Button(
tag_button_frame, text="Checkout Selected Tag", width=button_width_tag,
command=self.checkout_tag_callback, state=tk.DISABLED
)
self.checkout_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3)
self.create_tooltip(self.checkout_tag_button,
"Switch to selected tag (Detached HEAD).")
# Edit .gitignore button moved here
self.edit_gitignore_button = ttk.Button(
tag_button_frame, text="Edit .gitignore", width=button_width_tag,
command=self.open_gitignore_editor_callback, state=tk.DISABLED
)
self.edit_gitignore_button.pack(side=tk.TOP, fill=tk.X, pady=(3, 0))
self.create_tooltip(self.edit_gitignore_button,
"Open editor for the .gitignore file.")
def _create_function_frame(self):
"""Creates the frame holding the main Core Action buttons."""
self.function_frame = ttk.LabelFrame(
self, text="Core Actions", padding=(10, 10)
)
self.function_frame.pack(pady=(10, 5), fill="x", anchor=tk.N)
button_subframe = ttk.Frame(self.function_frame)
# Center the buttons within the frame
button_subframe.pack()
self.prepare_svn_button = ttk.Button(
button_subframe, text="Prepare SVN Repo",
command=self.prepare_svn_for_git_callback
)
self.prepare_svn_button.pack(side=tk.LEFT, padx=(0,5), pady=5)
self.create_bundle_button = ttk.Button(
button_subframe, text="Create Bundle",
command=self.create_git_bundle_callback
)
self.create_bundle_button.pack(side=tk.LEFT, padx=5, pady=5)
self.fetch_bundle_button = ttk.Button(
button_subframe, text="Fetch from Bundle",
command=self.fetch_from_git_bundle_callback
)
self.fetch_bundle_button.pack(side=tk.LEFT, padx=5, pady=5)
self.manual_backup_button = ttk.Button(
button_subframe, text="Backup Now (ZIP)",
command=self.manual_backup_callback
)
self.manual_backup_button.pack(side=tk.LEFT, padx=5, pady=5)
def _create_log_area(self):
"""Creates the scrolled text area for logging output."""
log_frame = ttk.Frame(self.master)
log_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True,
padx=10, pady=(5, 10))
self.log_text = scrolledtext.ScrolledText(
log_frame, height=8, width=100, # Adjusted height
font=("Consolas", 9), wrap=tk.WORD, state=tk.DISABLED
)
self.log_text.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)
def _initialize_profile_selection(self):
"""Sets the initial value of the profile dropdown."""
try:
from config_manager import DEFAULT_PROFILE
except ImportError:
DEFAULT_PROFILE = "default"
if DEFAULT_PROFILE in self.initial_profile_sections:
self.profile_var.set(DEFAULT_PROFILE)
elif self.initial_profile_sections:
self.profile_var.set(self.initial_profile_sections[0])
# else: profile_var remains empty
# --- GUI Update Methods ---
def toggle_backup_dir(self):
"""Enables/disables backup directory widgets based on checkbox."""
new_state = tk.NORMAL if self.autobackup_var.get() else tk.DISABLED
if hasattr(self, 'backup_dir_entry'):
self.backup_dir_entry.config(state=new_state)
if hasattr(self, 'backup_dir_button'):
self.backup_dir_button.config(state=new_state)
def browse_backup_dir(self):
"""Opens a directory selection dialog for backup directory."""
initial = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR
dirname = filedialog.askdirectory(
initialdir=initial, title="Select Backup Dir", parent=self.master
)
if dirname:
self.backup_dir_var.set(dirname)
def update_svn_indicator(self, is_prepared):
"""Updates only the indicator color and Prepare button state."""
color = self.GREEN if is_prepared else self.RED
state = tk.DISABLED if is_prepared else tk.NORMAL
tip = "Repo Prepared" if is_prepared else "Repo Not Prepared"
if hasattr(self, 'svn_status_indicator'):
self.svn_status_indicator.config(background=color)
self.update_tooltip(self.svn_status_indicator, tip)
if hasattr(self, 'prepare_svn_button'):
self.prepare_svn_button.config(state=state)
def update_profile_dropdown(self, sections):
"""Updates the profile combobox list."""
if hasattr(self, 'profile_dropdown'):
current = self.profile_var.get()
self.profile_dropdown['values'] = sections
# Maintain selection logic
if sections:
if current in sections: self.profile_var.set(current)
elif "default" in sections: self.profile_var.set("default")
else: self.profile_var.set(sections[0])
else: self.profile_var.set("")
def update_tag_list(self, tags_with_subjects):
"""Clears and repopulates tag listbox with name and subject."""
if not hasattr(self, 'tag_listbox'):
logging.error("Tag listbox missing for update.")
return
try:
self.tag_listbox.delete(0, tk.END)
if tags_with_subjects:
# Reset color if needed
try:
if self.tag_listbox.cget("fg") == "grey":
self.tag_listbox.config(fg='SystemWindowText')
except tk.TclError: pass
# Insert items
for name, subject in tags_with_subjects:
display = f"{name}\t({subject})"
self.tag_listbox.insert(tk.END, display)
else:
# Show placeholder
self.tag_listbox.insert(tk.END, "(No tags found)")
try: self.tag_listbox.config(fg="grey")
except tk.TclError: pass
except tk.TclError as e: logging.error(f"TclError updating tags: {e}")
except Exception as e: logging.error(f"Error updating tags: {e}", exc_info=True)
def get_selected_tag(self):
"""Returns the name only of the selected tag."""
if hasattr(self, 'tag_listbox'):
indices = self.tag_listbox.curselection()
if indices:
item = self.tag_listbox.get(indices[0])
if item != "(No tags found)":
name = item.split('\t', 1)[0] # Text before first tab
return name.strip()
return None
def update_branch_list(self, branches):
"""Clears and repopulates the branch listbox."""
if not hasattr(self, 'branch_listbox'):
logging.error("Branch listbox missing for update.")
return
try:
current_branch = self.current_branch_var.get() # Get displayed name
self.branch_listbox.delete(0, tk.END)
if branches:
# Reset color if needed
try:
if self.branch_listbox.cget("fg") == "grey":
self.branch_listbox.config(fg='SystemWindowText')
except tk.TclError: pass
# Insert branches, highlight current
for branch in branches:
is_current = (branch == current_branch)
display_name = f"* {branch}" if is_current else f" {branch}"
self.branch_listbox.insert(tk.END, display_name)
# Apply styling for current branch
if is_current:
self.branch_listbox.itemconfig(
tk.END, {'fg': 'blue', 'selectbackground': 'lightblue'}
)
else:
# Show placeholder
self.branch_listbox.insert(tk.END, "(No local branches?)")
try: self.branch_listbox.config(fg="grey")
except tk.TclError: pass
except tk.TclError as e: logging.error(f"TclError updating branches: {e}")
except Exception as e: logging.error(f"Error updating branches: {e}", exc_info=True)
def get_selected_branch(self):
"""Returns the name only of the selected branch."""
if hasattr(self, 'branch_listbox'):
indices = self.branch_listbox.curselection()
if indices:
item = self.branch_listbox.get(indices[0])
# Remove potential '*' prefix and strip whitespace
return item.lstrip("* ").strip()
return None
def set_current_branch_display(self, branch_name):
"""Updates the label showing the current branch."""
if hasattr(self, 'current_branch_var'):
if branch_name:
self.current_branch_var.set(branch_name)
else:
# Handle detached HEAD or error state more clearly
self.current_branch_var.set("(DETACHED or N/A)")
# --- Dialog Wrappers ---
def ask_new_profile_name(self):
return simpledialog.askstring("Add Profile", "Enter new profile name:", parent=self.master)
def show_error(self, title, message):
messagebox.showerror(title, message, parent=self.master)
def show_info(self, title, message):
messagebox.showinfo(title, message, parent=self.master)
def show_warning(self, title, message):
messagebox.showwarning(title, message, parent=self.master)
def ask_yes_no(self, title, message):
return messagebox.askyesno(title, message, parent=self.master)
# --- Tooltip Helpers ---
def create_tooltip(self, widget, text):
tooltip = Tooltip(widget, text)
widget.bind("<Enter>", lambda e, tt=tooltip: tt.showtip(), add='+')
widget.bind("<Leave>", lambda e, tt=tooltip: tt.hidetip(), add='+')
widget.bind("<ButtonPress>", lambda e, tt=tooltip: tt.hidetip(), add='+')
def update_tooltip(self, widget, text):
# Simple recreation is often easiest
widget.unbind("<Enter>")
widget.unbind("<Leave>")
widget.unbind("<ButtonPress>")
self.create_tooltip(widget, text)