# 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.""" # Hide any existing tooltip first self.hidetip() # Avoid error if widget is destroyed before showing tooltip if not self.widget.winfo_exists(): return 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 (e.g., widget hidden) 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 etc.) 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 # Reset the reference self.tooltip_window = None # Destroy the window if it exists 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 --- base_filename = os.path.basename(gitignore_path) self.title(f"Edit {base_filename}") self.geometry("600x450") # Initial size self.minsize(400, 300) # Minimum resizeable dimensions self.grab_set() # Make window modal (grab all events) self.transient(master) # Keep window on top of parent # Handle closing via window manager (X button) self.protocol("WM_DELETE_WINDOW", self._on_close) # --- Widgets --- # 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 content self.text_editor = scrolledtext.ScrolledText( main_frame, wrap=tk.WORD, font=("Consolas", 10), # Monospaced font undo=True # Enable undo/redo functionality ) 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) # Place in 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) # Place in left-center # --- Load Initial Content --- self._load_file() # Center window relative to parent after creation self._center_window(master) # Set initial focus to the text editor self.text_editor.focus_set() def _center_window(self, parent): """Centers the editor window relative to its parent.""" self.update_idletasks() # Process pending geometry changes # Get parent window geometry and position parent_x = parent.winfo_rootx() parent_y = parent.winfo_rooty() parent_width = parent.winfo_width() parent_height = parent.winfo_height() # Get self (editor window) geometry win_width = self.winfo_width() win_height = self.winfo_height() # Calculate position for centering x_pos = parent_x + (parent_width // 2) - (win_width // 2) y_pos = parent_y + (parent_height // 2) - (win_height // 2) # Basic screen boundary check to prevent window going off-screen 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 the calculated position using wm_geometry 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 to empty content if os.path.exists(self.gitignore_path): # Read file content with specified encoding and error handling with open(self.gitignore_path, 'r', encoding='utf-8', errors='replace') as f: content = f.read() self.logger.debug(".gitignore content loaded successfully.") else: # File doesn't exist self.logger.info(f"'{self.gitignore_path}' does not exist.") # Store original content and update editor text self.original_content = content self.text_editor.delete("1.0", tk.END) # Clear existing content first self.text_editor.insert(tk.END, self.original_content) # Reset undo stack after programmatically changing text self.text_editor.edit_reset() except IOError as e: # Handle file reading errors specifically self.logger.error(f"Error reading {self.gitignore_path}: {e}", exc_info=True) messagebox.showerror( "Error Reading File", f"Could not read the .gitignore file:\n{e}", parent=self # Show error relative to this dialog ) except Exception as e: # Handle other unexpected errors during file loading self.logger.exception(f"Unexpected error loading file: {e}") messagebox.showerror( "Unexpected Error", f"An unexpected error occurred loading the file:\n{e}", parent=self ) def _save_file(self): """Saves the current editor content to the .gitignore file.""" # Get content from text widget, remove trailing whitespace current_content = self.text_editor.get("1.0", tk.END).rstrip() # Add a single trailing newline if content is not empty if current_content: current_content += "\n" # Normalize original content similarly for accurate comparison normalized_original = self.original_content.rstrip() if normalized_original: normalized_original += "\n" # Check if content has actually changed if current_content == normalized_original: self.logger.info("No changes detected in .gitignore. Skipping save.") return True # Indicate success (no action needed) # Proceed with saving if content changed self.logger.info(f"Saving changes to: {self.gitignore_path}") try: # Write content to file with UTF-8 encoding and consistent newline with open(self.gitignore_path, 'w', encoding='utf-8', newline='\n') as f: f.write(current_content) self.logger.info(".gitignore file saved successfully.") # Update original content baseline after successful save self.original_content = current_content # Reset undo stack after saving changes self.text_editor.edit_reset() return True # Indicate save success except IOError as e: # Handle file writing errors self.logger.error(f"Error writing {self.gitignore_path}: {e}", exc_info=True) messagebox.showerror("Error Saving File", f"Could not save the .gitignore file:\n{e}", parent=self) return False # Indicate save failure except Exception as e: # Handle other unexpected errors during saving self.logger.exception(f"Unexpected error saving file: {e}") messagebox.showerror("Unexpected Error", f"An unexpected error occurred saving file:\n{e}", parent=self) return False def _save_and_close(self): """Saves the file and closes the window if save is successful.""" save_successful = self._save_file() if save_successful: # Close window only if save succeeded or no changes were made self.destroy() def _on_close(self): """Handles closing the window (Cancel button or WM close button).""" # Get current content and normalize it current_content = self.text_editor.get("1.0", tk.END).rstrip() if current_content: current_content += "\n" # Normalize original content normalized_original = self.original_content.rstrip() if normalized_original: normalized_original += "\n" # Check if content has changed since loading/last save if current_content != normalized_original: # Ask user about saving changes (Yes/No/Cancel) response = messagebox.askyesnocancel( "Unsaved Changes", "You have unsaved changes.\nSave before closing?", parent=self ) if response is True: # User chose Yes (Save) # Attempt save, close only if successful self._save_and_close() elif response is False: # User chose No (Discard) self.logger.warning("Discarding unsaved changes in editor.") self.destroy() # Close immediately # else (response is None - Cancel): Do nothing, keep window open else: # No changes detected, simply close the window 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 # Call Dialog constructor AFTER initializing variables 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") # Configure column to allow entry widgets to expand horizontally master.columnconfigure(1, weight=1) # Return the widget that should have initial focus return self.name_entry 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() # Check for empty fields 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 = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(?") # --- Create Main Layout Sections --- self._create_profile_frame() self._create_notebook_with_tabs() self._create_function_frame() self._create_log_area() # --- Initial State Configuration --- self._initialize_profile_selection() self.toggle_backup_dir() 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=(0, 5), fill="x") self.profile_frame.columnconfigure(1, weight=1) profile_label = ttk.Label(self.profile_frame, text="Profile:") profile_label.grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) 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) self.profile_dropdown.bind( "<>", lambda e: self.load_profile_settings_callback(self.profile_var.get()) ) self.profile_var.trace_add( "write", lambda *a: self.load_profile_settings_callback(self.profile_var.get()) ) 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 settings for selected profile.") 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) 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_notebook_with_tabs(self): """Creates the main Notebook widget and its tabs.""" self.notebook = ttk.Notebook(self, padding=(0, 5, 0, 0)) self.notebook.pack(pady=5, padx=0, fill="both", expand=True) # Create frames for each tab's content self.setup_tab_frame = ttk.Frame(self.notebook, padding=(10)) self.commit_branch_tab_frame = ttk.Frame(self.notebook, padding=(10)) self.tags_gitignore_tab_frame = ttk.Frame(self.notebook, padding=(10)) # Add frames as tabs self.notebook.add(self.setup_tab_frame, text=' Setup & Backup ') self.notebook.add(self.commit_branch_tab_frame, text=' Commit & Branches ') self.notebook.add(self.tags_gitignore_tab_frame, text=' Tags & Gitignore ') # Populate each tab with its widgets self._populate_setup_tab(self.setup_tab_frame) self._populate_commit_branch_tab(self.commit_branch_tab_frame) self._populate_tags_gitignore_tab(self.tags_gitignore_tab_frame) def _populate_setup_tab(self, parent_tab_frame): """Creates and places widgets for the Setup & Backup tab.""" # Create and pack sub-frames within this tab repo_paths_frame = self._create_repo_paths_frame(parent_tab_frame) repo_paths_frame.pack(pady=(0, 5), fill="x", expand=False) backup_config_frame = self._create_backup_config_frame(parent_tab_frame) backup_config_frame.pack(pady=5, fill="x", expand=False) def _create_repo_paths_frame(self, parent): """Creates the sub-frame for repository paths and bundle names.""" frame = ttk.LabelFrame(parent, text="Repository & Bundle Paths", padding=(10, 5)) col_label = 0 col_entry = 1 col_button = 2 col_indicator = 3 # Configure entry column to expand horizontally frame.columnconfigure(col_entry, weight=1) # Row 0: SVN Path svn_label = ttk.Label(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(frame, width=60) self.svn_path_entry.grid(row=0, column=col_entry, sticky=tk.EW, padx=5, pady=3) # Bind events to trigger status updates self.svn_path_entry.bind("", lambda e: self.update_svn_status_callback(self.svn_path_entry.get())) self.svn_path_entry.bind("", lambda e: self.update_svn_status_callback(self.svn_path_entry.get())) # Button uses local browse_folder method self.svn_path_browse_button = ttk.Button( frame, text="Browse...", width=9, command=lambda w=self.svn_path_entry: self.browse_folder(w) ) self.svn_path_browse_button.grid(row=0, column=col_button, sticky=tk.W, padx=(0, 5), pady=3) # Status Indicator self.svn_status_indicator = tk.Label( 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 usb_label = ttk.Label(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(frame, width=60) self.usb_path_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=3) # Button uses local browse_folder method self.usb_path_browse_button = ttk.Button( frame, text="Browse...", width=9, command=lambda w=self.usb_path_entry: self.browse_folder(w) ) self.usb_path_browse_button.grid(row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=3) # Row 2: Create Bundle Name create_label = ttk.Label(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(frame, width=60) self.bundle_name_entry.grid(row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) # Row 3: Fetch Bundle Name fetch_label = ttk.Label(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(frame, width=60) self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) return frame def _create_backup_config_frame(self, parent): """Creates the sub-frame for backup configuration.""" frame = ttk.LabelFrame(parent, text="Backup Configuration (ZIP)", padding=(10, 5)) col_label = 0 col_entry = 1 col_button = 2 frame.columnconfigure(col_entry, weight=1) # Entry expands # Row 0: Autobackup Checkbox self.autobackup_checkbox = ttk.Checkbutton( 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 backup_dir_label = ttk.Label(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( 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) # Button uses local browse_backup_dir method self.backup_dir_button = ttk.Button( frame, text="Browse...", width=9, command=self.browse_backup_dir, # Call local method 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 exclude_label = ttk.Label(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( 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 (e.g., .log,.tmp)") return frame def _populate_commit_branch_tab(self, parent_tab_frame): """Creates and places widgets for the Commit & Branches tab.""" # Configure overall tab columns/rows for expansion parent_tab_frame.columnconfigure(0, weight=1) # Listbox column expands parent_tab_frame.rowconfigure(1, weight=1) # Branch subframe row expands # Create Commit sub-frame (positioned at top) commit_subframe = self._create_commit_management_frame(parent_tab_frame) commit_subframe.grid(row=0, column=0, columnspan=2, sticky="ew", padx=0, pady=(0, 10)) # Create Branch sub-frame (positioned below commit) branch_subframe = self._create_branch_management_frame(parent_tab_frame) branch_subframe.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=0, pady=0) def _create_commit_management_frame(self, parent): """Creates the sub-frame for commit message and actions.""" frame = ttk.LabelFrame(parent, text="Commit", padding=5) # Configure internal columns frame.columnconfigure(1, weight=1) # Entry expands # Row 0: Autocommit Checkbox self.autocommit_checkbox = ttk.Checkbutton( 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, sticky="w", padx=5, pady=(5, 3)) self.create_tooltip(self.autocommit_checkbox, "If checked, commit changes before Create Bundle.") # Row 1: Commit Message Entry + Manual Commit Button commit_msg_label = ttk.Label(frame, text="Commit Message:") commit_msg_label.grid(row=1, column=0, sticky="w", padx=5, pady=3) self.commit_message_entry = ttk.Entry( 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 autocommit.") # Manual Commit Button self.commit_button = ttk.Button( 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 this message.") return frame def _create_branch_management_frame(self, parent): """Creates the sub-frame for branch operations.""" frame = ttk.LabelFrame(parent, text="Branches", padding=5) # Configure grid columns within this frame frame.columnconfigure(1, weight=1) # Listbox column expands frame.rowconfigure(2, weight=1) # Listbox row expands # Row 0: Current Branch Display current_branch_label = ttk.Label(frame, text="Current Branch:") current_branch_label.grid(row=0, column=0, sticky="w", padx=5, pady=3) self.current_branch_display = ttk.Label( 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 display across listbox and button columns? Or just listbox? 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 state.") # Row 1: Listbox Label branch_list_label = ttk.Label(frame, text="Local Branches:") # Span label across all columns below it branch_list_label.grid(row=1, column=0, columnspan=4, sticky="w", padx=5, pady=(10, 0)) # Row 2: Listbox + Scrollbar Frame (Spans first 3 columns) branch_list_frame = ttk.Frame(frame) 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") 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(frame) # Place it in the 4th column (index 3), aligned with listbox row branch_button_frame.grid(row=2, column=3, sticky="ns", padx=(10, 5), pady=(0, 5)) button_width_branch = 18 # Consistent width 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 selected local branch.") return frame # Return the created frame def _populate_tags_gitignore_tab(self, parent_tab_frame): """Creates and places widgets for the Tags & Gitignore tab.""" # Configure grid: listbox expands, button column fixed width parent_tab_frame.columnconfigure(0, weight=1) parent_tab_frame.rowconfigure(0, weight=1) # Listbox row expands vertically # --- Tag Management Section (Left part of the tab) --- tag_list_frame = self._create_tag_management_frame(parent_tab_frame) # Span both rows (tag list and potential future rows) tag_list_frame.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, 5), pady=5) # --- Tag/Gitignore Actions Section (Right part, vertical buttons) --- tag_action_frame = self._create_tag_action_frame(parent_tab_frame) # Span both rows to align vertically tag_action_frame.grid(row=0, column=1, rowspan=2, sticky="ns", padx=(5, 0), pady=5) def _create_tag_management_frame(self, parent): """Creates the sub-frame containing the tag listbox.""" frame = ttk.LabelFrame(parent, text="Tags", padding=5) # Configure internal grid for expansion frame.rowconfigure(0, weight=1) frame.columnconfigure(0, weight=1) # Listbox for tags self.tag_listbox = tk.Listbox( frame, height=8, # More visible rows for tags exportselection=False, selectmode=tk.SINGLE, font=("Consolas", 9) # Monospaced font for alignment ) self.tag_listbox.grid(row=0, column=0, sticky="nsew") # Scrollbar for tag listbox tag_scrollbar = ttk.Scrollbar( 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 for actions.") return frame def _create_tag_action_frame(self, parent): """Creates the vertical frame for Tag and Gitignore action buttons.""" frame = ttk.Frame(parent) # Simple frame container # Consistent button width for this column button_width = 18 # Refresh Tags Button self.refresh_tags_button = ttk.Button( frame, text="Refresh Tags", width=button_width, 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.") # Create Tag Button self.create_tag_button = ttk.Button( frame, text="Create Tag...", width=button_width, 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 (if message provided) & create tag.") # Checkout Tag Button self.checkout_tag_button = ttk.Button( frame, text="Checkout Selected Tag", width=button_width, 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).") # Delete Tag Button self.delete_tag_button = ttk.Button( frame, text="Delete Selected Tag", width=button_width, command=self.delete_tag_callback, state=tk.DISABLED # Connect callback ) self.delete_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) self.create_tooltip(self.delete_tag_button, "Delete selected tag locally.") # Edit .gitignore Button self.edit_gitignore_button = ttk.Button( frame, text="Edit .gitignore", width=button_width, 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.") return frame def _create_function_frame(self): """Creates the frame holding the Core Action buttons (below tabs).""" self.function_frame = ttk.LabelFrame( self, text="Core Actions", padding=(10, 10) ) # Pack below notebook, but above log area self.function_frame.pack(pady=(5, 5), fill="x", anchor=tk.N) # Sub-frame to center the buttons horizontally button_subframe = ttk.Frame(self.function_frame) button_subframe.pack() # Default pack centers content # Prepare SVN button 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) # Create Bundle button 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) # Fetch Bundle button 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) # Manual Backup Button 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) # Attach to root window # Pack at the very bottom, allow expansion log_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) # Padding only below 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" # Fallback # Set dropdown value based on available profiles if DEFAULT_PROFILE in self.initial_profile_sections: self.profile_var.set(DEFAULT_PROFILE) elif self.initial_profile_sections: # Select first available profile if default not found self.profile_var.set(self.initial_profile_sections[0]) # else: profile_var remains empty if no profiles exist # --- ADDED: browse_folder method (moved from GitUtilityApp) --- def browse_folder(self, entry_widget): """ Opens a folder selection dialog and updates the specified Entry widget. """ # Suggest initial directory current_path = entry_widget.get() initial_dir = current_path if os.path.isdir(current_path) else \ os.path.expanduser("~") # Show dialog using tkinter's filedialog directory = filedialog.askdirectory( initialdir=initial_dir, title="Select Directory", parent=self.master # Make dialog modal ) if directory: # If a directory was selected # Update the entry widget entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) # Trigger controller's status update if SVN path changed if entry_widget == self.svn_path_entry: self.update_svn_status_callback(directory) # else: User cancelled # --- 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 folder dialog specifically for the backup directory.""" # Use the local browse_folder method for consistency if hasattr(self, 'backup_dir_entry'): self.browse_folder(self.backup_dir_entry) 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" # Update indicator's background and tooltip if hasattr(self, 'svn_status_indicator'): self.svn_status_indicator.config(background=color) self.update_tooltip(self.svn_status_indicator, tip) # Update Prepare button's state 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() # Set new values for the dropdown self.profile_dropdown['values'] = sections # Maintain selection logic if sections: if current in sections: # Setting same value might not trigger trace, but is correct state self.profile_var.set(current) elif "default" in sections: self.profile_var.set("default") # Triggers load else: self.profile_var.set(sections[0]) # Triggers load else: self.profile_var.set("") # Triggers load with empty def update_tag_list(self, tags_with_subjects): """Clears and repopulates tag listbox with name and subject.""" if not hasattr(self, 'tag_listbox'): # Log error if listbox doesn't exist when called logging.error("Cannot update tag list: Listbox widget not found.") return try: self.tag_listbox.delete(0, tk.END) # Clear list if tags_with_subjects: # Reset text color if it was previously greyed out try: current_fg = self.tag_listbox.cget("fg") if current_fg == "grey": # Use standard system text color name self.tag_listbox.config(fg='SystemWindowText') except tk.TclError: # Fallback if SystemWindowText is unknown try: self.tag_listbox.config(fg='black') except tk.TclError: pass # Ignore color setting errors if all fails # Insert formatted tag strings for name, subject in tags_with_subjects: # Use tab separation for basic alignment display = f"{name}\t({subject})" self.tag_listbox.insert(tk.END, display) else: # Show placeholder text if no tags self.tag_listbox.insert(tk.END, "(No tags found)") try: self.tag_listbox.config(fg="grey") # Dim placeholder text except tk.TclError: pass # Ignore color setting errors except tk.TclError as e: logging.error(f"TclError updating tag listbox: {e}") except Exception as e: logging.error(f"Error updating tag listbox: {e}", exc_info=True) def get_selected_tag(self): """Returns the name only of the selected tag.""" tag_name = None if hasattr(self, 'tag_listbox'): indices = self.tag_listbox.curselection() # Check if there is a selection (curselection returns tuple) if indices: selected_index = indices[0] # Get the index item = self.tag_listbox.get(selected_index) # Get text at index # Ignore placeholder text if item != "(No tags found)": # Extract name (text before the first tab) tag_name = item.split('\t', 1)[0] tag_name = tag_name.strip() # Remove any extra spaces return tag_name # Return name or 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 = self.current_branch_var.get() # Get displayed current branch self.branch_listbox.delete(0, tk.END) # Clear list 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) # Add '*' prefix for current branch display display_name = f"* {branch}" if is_current else f" {branch}" self.branch_listbox.insert(tk.END, display_name) # Apply styling for current branch (if needed) if is_current: self.branch_listbox.itemconfig( tk.END, {'fg': 'blue', 'selectbackground': 'lightblue'} ) else: # Show placeholder if no branches self.branch_listbox.insert(tk.END, "(No local branches?)") try: self.branch_listbox.config(fg="grey") # Dim placeholder 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.""" branch_name = None if hasattr(self, 'branch_listbox'): indices = self.branch_listbox.curselection() if indices: item = self.branch_listbox.get(indices[0]) # Remove potential '*' prefix and leading/trailing whitespace branch_name = item.lstrip("* ").strip() return branch_name # Return name or None def set_current_branch_display(self, branch_name): """Updates the label showing the current branch.""" if hasattr(self, 'current_branch_var'): # Set display text, handling None or empty string display_text = branch_name if branch_name else "(DETACHED or N/A)" self.current_branch_var.set(display_text) # --- Dialog Wrappers --- def ask_new_profile_name(self): """Asks the user for a new profile name.""" return simpledialog.askstring("Add Profile", "Enter new profile name:", parent=self.master) def show_error(self, title, message): """Displays an error message box.""" messagebox.showerror(title, message, parent=self.master) def show_info(self, title, message): """Displays an information message box.""" messagebox.showinfo(title, message, parent=self.master) def show_warning(self, title, message): """Displays a warning message box.""" messagebox.showwarning(title, message, parent=self.master) def ask_yes_no(self, title, message): """Displays a yes/no confirmation dialog.""" return messagebox.askyesno(title, message, parent=self.master) # --- Tooltip Helpers --- def create_tooltip(self, widget, text): """Creates a tooltip for a given widget.""" tooltip = Tooltip(widget, text) # Use add='+' to ensure other bindings are not overwritten widget.bind("", lambda e, tt=tooltip: tt.showtip(), add='+') widget.bind("", lambda e, tt=tooltip: tt.hidetip(), add='+') # Hide tooltip also when clicking the widget widget.bind("", lambda e, tt=tooltip: tt.hidetip(), add='+') def update_tooltip(self, widget, text): """Updates the text of an existing tooltip (by re-creating it).""" # Simple approach: Remove old bindings and create new tooltip widget.unbind("") widget.unbind("") widget.unbind("") # Re-create the tooltip with the new text self.create_tooltip(widget, text)