From 59d32cb479ecc0dc60a8dc0d40972b71756d4ec2 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 9 Apr 2025 13:39:00 +0200 Subject: [PATCH] versione con tab --- GitTool.py | 1324 --------------------------------------------- GitUtility.ico | Bin 0 -> 81643 bytes GitUtility.py | 310 +++++++---- GitUtility.spec | 44 ++ action_handler.py | 68 ++- create_exe.bat | 0 gui.py | 622 +++++++++++---------- 7 files changed, 620 insertions(+), 1748 deletions(-) delete mode 100644 GitTool.py create mode 100644 GitUtility.ico create mode 100644 GitUtility.spec create mode 100644 create_exe.bat diff --git a/GitTool.py b/GitTool.py deleted file mode 100644 index 21d7154..0000000 --- a/GitTool.py +++ /dev/null @@ -1,1324 +0,0 @@ -# GitTool.py -import os -import shutil -import datetime -import tkinter as tk -from tkinter import messagebox -import logging -import zipfile - -# Import application modules -from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR -from git_commands import GitCommands, GitCommandError -from logger_config import setup_logger -# Import GUI classes, including the new dialog -from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog - -class GitSvnSyncApp: - """ - Main application class for the Git SVN Sync Tool. - Coordinates the GUI, configuration, and Git command execution. - """ - - def __init__(self, master): - """ - Initializes the GitSvnSyncApp. - - Args: - master (tk.Tk): The main Tkinter root window. - """ - self.master = master - master.title("Git SVN Sync Tool") - # Handle window close event gracefully - master.protocol("WM_DELETE_WINDOW", self.on_closing) - - # --- Early Logger Setup --- - # Basic config first in case setup_logger has issues - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s" - ) - # Get the application-specific logger instance - self.logger = logging.getLogger("GitSvnSyncApp") - - # --- Configuration Manager --- - # Initialize ConfigManager - try: - self.config_manager = ConfigManager(self.logger) - except Exception as e: - self.logger.critical( - f"Failed to initialize ConfigManager: {e}", - exc_info=True - ) - self.show_fatal_error( - f"Failed to load or create configuration file.\n{e}\n" - f"Application cannot continue." - ) - # Ensure window is destroyed if initialization fails critically - master.destroy() - # Stop initialization if config fails - return - - # --- GUI Main Frame --- - # Create the main GUI frame, passing required callbacks - try: - self.main_frame = MainFrame( - master, - load_profile_settings_cb=self.load_profile_settings, - browse_folder_cb=self.browse_folder, - update_svn_status_cb=self.update_svn_status_indicator, - prepare_svn_for_git_cb=self.prepare_svn_for_git, - create_git_bundle_cb=self.create_git_bundle, - fetch_from_git_bundle_cb=self.fetch_from_git_bundle, - config_manager_instance=self.config_manager, - profile_sections_list=self.config_manager.get_profile_sections(), - add_profile_cb=self.add_profile, - remove_profile_cb=self.remove_profile, - manual_backup_cb=self.manual_backup, - open_gitignore_editor_cb=self.open_gitignore_editor, - save_profile_cb=self.save_profile_settings, # Save button callback - # Pass Tag Management Callbacks - refresh_tags_cb=self.refresh_tag_list, - create_tag_cb=self.create_tag, # Triggers new flow - checkout_tag_cb=self.checkout_tag - ) - except Exception as e: - self.logger.critical( - f"Failed to initialize MainFrame GUI: {e}", - exc_info=True - ) - self.show_fatal_error( - f"Failed to create the main application window.\n{e}\n" - f"Application cannot continue." - ) - master.destroy() - return - - # --- Enhanced Logger Setup --- - # Configure logger using the GUI widget - self.logger = setup_logger(self.main_frame.log_text) - # Update ConfigManager's logger instance - self.config_manager.logger = self.logger - - # --- Git Commands Handler --- - # Initialize GitCommands (decoupled from GUI) - self.git_commands = GitCommands(self.logger) - - # --- Initial Application State --- - self.logger.info("Application initializing...") - # Load settings for the initially selected profile - # This is triggered by the trace on profile_var in MainFrame's __init__ - initial_profile = self.main_frame.profile_var.get() - if initial_profile: - self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...") - # load_profile_settings is called via trace and handles tag refresh - else: - self.logger.warning("No profile selected on startup.") - # Clear fields and disable buttons if no profile is selected - self._clear_and_disable_fields() - - self.logger.info("Application started successfully.") - - - def on_closing(self): - """Handles the event when the user tries to close the window.""" - self.logger.info("Close button clicked. Preparing to exit.") - # TODO: Add checks for unsaved changes or running operations if needed - # For example, check if GitignoreEditorWindow is open and modified. - self.logger.info("Application closing.") - self.master.destroy() - - - # --- Profile Management Callbacks/Methods --- - - def load_profile_settings(self, profile_name): - """Loads settings for the specified profile into the GUI fields.""" - self.logger.info(f"Loading settings for profile: '{profile_name}'") - - # Handle case where no profile is selected (e.g., after removing last one) - if not profile_name: - self.logger.warning("Attempted load settings with no profile selected.") - self._clear_and_disable_fields() - return - - # Check if the profile actually exists in the configuration - if profile_name not in self.config_manager.get_profile_sections(): - self.logger.error(f"Profile '{profile_name}' not found in config.") - self.main_frame.show_error( - "Profile Error", - f"Profile '{profile_name}' not found." - ) - self._clear_and_disable_fields() - return - - # Load values using ConfigManager with appropriate fallbacks - cm = self.config_manager # Alias for brevity - svn_path = cm.get_profile_option(profile_name, "svn_working_copy_path", "") - usb_path = cm.get_profile_option(profile_name, "usb_drive_path", "") - bundle_name = cm.get_profile_option(profile_name, "bundle_name", "") - bundle_upd_name = cm.get_profile_option(profile_name, "bundle_name_updated", "") - autocommit_str = cm.get_profile_option(profile_name, "autocommit", "False") - commit_msg = cm.get_profile_option(profile_name, "commit_message", "") - autobackup_str = cm.get_profile_option(profile_name, "autobackup", "False") - backup_dir = cm.get_profile_option(profile_name, "backup_dir", - DEFAULT_BACKUP_DIR) - excludes = cm.get_profile_option(profile_name, "backup_exclude_extensions", - ".log,.tmp") - - # Update GUI Elements safely (check if main_frame exists) - if hasattr(self, 'main_frame'): - mf = self.main_frame # Alias - - # Update Repository Frame widgets - mf.svn_path_entry.delete(0, tk.END) - mf.svn_path_entry.insert(0, svn_path) - mf.usb_path_entry.delete(0, tk.END) - mf.usb_path_entry.insert(0, usb_path) - mf.bundle_name_entry.delete(0, tk.END) - mf.bundle_name_entry.insert(0, bundle_name) - mf.bundle_updated_name_entry.delete(0, tk.END) - mf.bundle_updated_name_entry.insert(0, bundle_upd_name) - - # Update Commit/Tag Frame widgets - mf.commit_message_var.set(commit_msg) - mf.autocommit_var.set(autocommit_str.lower() == "true") - - # Update Backup Frame widgets - mf.autobackup_var.set(autobackup_str.lower() == "true") - mf.backup_dir_var.set(backup_dir) - mf.backup_exclude_extensions_var.set(excludes) - # Update state of backup dir entry based on checkbox - mf.toggle_backup_dir() - - # Update status indicator and dependent buttons - self.update_svn_status_indicator(svn_path) - # Enable general function buttons - self._enable_function_buttons() - - # Refresh tag list if the repo is valid and prepared - repo_is_ready = ( - svn_path and - os.path.isdir(svn_path) and - os.path.exists(os.path.join(svn_path, ".git")) - ) - if repo_is_ready: - self.refresh_tag_list() - else: - # Clear tag list if path invalid or repo not prepared - mf.update_tag_list([]) - - self.logger.info(f"Settings loaded successfully for '{profile_name}'.") - else: - self.logger.error("Cannot load settings: Main frame unavailable.") - - - def save_profile_settings(self): - """Saves the current GUI field values to the selected profile.""" - profile = self.main_frame.profile_var.get() - if not profile: - self.logger.warning("Cannot save settings: No profile selected.") - # Show error only if explicitly triggered by user via Save button? - # self.main_frame.show_error("Save Error", "No profile selected.") - return False # Indicate failure - - self.logger.info(f"Saving settings for profile: '{profile}'") - try: - cm = self.config_manager # Alias - mf = self.main_frame # Alias - - # Save Repository settings - cm.set_profile_option(profile, "svn_working_copy_path", - mf.svn_path_entry.get()) - cm.set_profile_option(profile, "usb_drive_path", - mf.usb_path_entry.get()) - cm.set_profile_option(profile, "bundle_name", - mf.bundle_name_entry.get()) - cm.set_profile_option(profile, "bundle_name_updated", - mf.bundle_updated_name_entry.get()) - - # Save Commit/Tag settings - cm.set_profile_option(profile, "autocommit", - str(mf.autocommit_var.get())) - cm.set_profile_option(profile, "commit_message", - mf.commit_message_var.get()) - - # Save Backup settings - cm.set_profile_option(profile, "autobackup", - str(mf.autobackup_var.get())) - cm.set_profile_option(profile, "backup_dir", - mf.backup_dir_var.get()) - cm.set_profile_option(profile, "backup_exclude_extensions", - mf.backup_exclude_extensions_var.get()) - - # Persist changes to the configuration file - cm.save_config() - - self.logger.info(f"Profile settings for '{profile}' saved successfully.") - # Optionally provide visual feedback on save success - # self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.") - return True # Indicate success - - except Exception as e: - self.logger.error(f"Error saving settings for '{profile}': {e}", - exc_info=True) - self.main_frame.show_error("Save Error", - f"Failed to save settings:\n{e}") - return False # Indicate failure - - - def add_profile(self): - """Handles adding a new profile.""" - self.logger.debug("'Add Profile' button clicked.") - # Get new profile name from user via dialog - new_profile_name = self.main_frame.ask_new_profile_name() - - if not new_profile_name: - # User cancelled the dialog - self.logger.info("Profile addition cancelled by user.") - return - - new_profile_name = new_profile_name.strip() - if not new_profile_name: - # Empty name provided - self.logger.warning("Attempted to add profile with empty name.") - self.main_frame.show_error("Error", "Profile name cannot be empty.") - return - - # Check if profile name already exists - if new_profile_name in self.config_manager.get_profile_sections(): - self.logger.warning(f"Profile name already exists: '{new_profile_name}'") - self.main_frame.show_error( - "Error", - f"Profile name '{new_profile_name}' already exists." - ) - return - - # Proceed with adding the profile - self.logger.info(f"Adding new profile: '{new_profile_name}'") - try: - # Get default values for all keys - defaults = self.config_manager._get_expected_keys_with_defaults() - # Customize defaults specific to a new profile - defaults["bundle_name"] = f"{new_profile_name}_repo.bundle" - defaults["bundle_name_updated"] = f"{new_profile_name}_update.bundle" - defaults["svn_working_copy_path"] = "" # Start with empty paths - defaults["usb_drive_path"] = "" # Start with empty paths - - # Set all default options for the new profile - for key, value in defaults.items(): - self.config_manager.set_profile_option(new_profile_name, key, value) - - # Save the updated configuration - self.config_manager.save_config() - - # Update the GUI dropdown list - updated_sections = self.config_manager.get_profile_sections() - self.main_frame.update_profile_dropdown(updated_sections) - # Select the newly added profile in the dropdown - # This will trigger load_profile_settings via the trace - self.main_frame.profile_var.set(new_profile_name) - - self.logger.info(f"Profile '{new_profile_name}' added successfully.") - - except Exception as e: - self.logger.error(f"Error adding profile '{new_profile_name}': {e}", - exc_info=True) - self.main_frame.show_error("Error", f"Failed to add profile:\n{e}") - - - def remove_profile(self): - """Handles removing the currently selected profile.""" - self.logger.debug("'Remove Profile' button clicked.") - profile_to_remove = self.main_frame.profile_var.get() - - if not profile_to_remove: - self.logger.warning("Attempted remove when no profile selected.") - self.main_frame.show_error("Error", "No profile selected to remove.") - return - - # Prevent removing the default profile - if profile_to_remove == DEFAULT_PROFILE: - self.logger.warning("Attempted to remove the default profile.") - self.main_frame.show_error( - "Error", f"Cannot remove the '{DEFAULT_PROFILE}' profile." - ) - return - - # Confirmation dialog - confirm_msg = (f"Are you sure you want to permanently remove profile " - f"'{profile_to_remove}'?") - if self.main_frame.ask_yes_no("Remove Profile", confirm_msg): - self.logger.info(f"Attempting remove profile: '{profile_to_remove}'") - try: - # Remove section via ConfigManager - success = self.config_manager.remove_profile_section( - profile_to_remove - ) - if success: - self.config_manager.save_config() # Save changes - self.logger.info("Profile removed successfully.") - # Update dropdown - new profile selection triggers load - updated_sections = self.config_manager.get_profile_sections() - self.main_frame.update_profile_dropdown(updated_sections) - else: - # ConfigManager should have logged reason - self.main_frame.show_error( - "Error", - f"Failed to remove profile '{profile_to_remove}'. See logs." - ) - - except Exception as e: - self.logger.error( - f"Unexpected error removing profile '{profile_to_remove}': {e}", - exc_info=True - ) - self.main_frame.show_error( - "Error", - f"An unexpected error occurred removing profile:\n{e}" - ) - else: - # User clicked 'No' in the confirmation dialog - self.logger.info("Profile removal cancelled by user.") - - - # --- GUI Interaction Callbacks --- - - def browse_folder(self, entry_widget): - """Opens folder dialog and updates the specified Tkinter Entry.""" - self.logger.debug("Browse folder requested.") - # Determine initial directory for dialog - current_path = entry_widget.get() - # Suggest current path if valid, else user's home directory - initial_dir = current_path if os.path.isdir(current_path) else \ - os.path.expanduser("~") - - # Show folder selection dialog - directory = filedialog.askdirectory( - initialdir=initial_dir, - title="Select Directory", - parent=self.master # Make dialog modal to main window - ) - - if directory: - # User selected a directory - self.logger.debug(f"Directory selected: {directory}") - # Update the entry widget's content - entry_widget.delete(0, tk.END) - entry_widget.insert(0, directory) - # If the SVN path entry was changed, trigger status update - if entry_widget == self.main_frame.svn_path_entry: - self.update_svn_status_indicator(directory) - else: - # User cancelled the dialog - self.logger.debug("Folder browse dialog cancelled.") - - - def update_svn_status_indicator(self, svn_path): - """ - Checks repo status, updates indicator, and enables/disables - Prepare, Edit Gitignore, and Commit/Tag widgets. - """ - # Determine directory validity and Git preparation status - is_valid_dir = bool(svn_path and os.path.isdir(svn_path)) - is_prepared = False - if is_valid_dir: - git_dir_path = os.path.join(svn_path, ".git") - is_prepared = os.path.exists(git_dir_path) - - self.logger.debug( - f"Updating status for '{svn_path}'. Valid: {is_valid_dir}, " - f"Prepared: {is_prepared}" - ) - - # Update GUI elements safely - if hasattr(self, 'main_frame'): - mf = self.main_frame # Alias - - # Update indicator and Prepare button via MainFrame method - mf.update_svn_indicator(is_prepared) - - # Determine state for other dependent widgets - # Edit Gitignore button needs a valid directory path - gitignore_state = tk.NORMAL if is_valid_dir else tk.DISABLED - # Commit/Tag widgets need a prepared Git repository - commit_tag_state = tk.NORMAL if is_prepared else tk.DISABLED - - # Update Edit Gitignore button state - if hasattr(mf, 'edit_gitignore_button'): - mf.edit_gitignore_button.config(state=gitignore_state) - - # Update Commit/Tag section widgets state - if hasattr(mf, 'commit_message_entry'): - mf.commit_message_entry.config(state=commit_tag_state) - if hasattr(mf, 'autocommit_checkbox'): - mf.autocommit_checkbox.config(state=commit_tag_state) - if hasattr(mf, 'refresh_tags_button'): - mf.refresh_tags_button.config(state=commit_tag_state) - if hasattr(mf, 'create_tag_button'): - mf.create_tag_button.config(state=commit_tag_state) - if hasattr(mf, 'checkout_tag_button'): - mf.checkout_tag_button.config(state=commit_tag_state) - - - def open_gitignore_editor(self): - """Opens the editor window for the .gitignore file.""" - self.logger.info("--- Action: Edit .gitignore ---") - # Validate the SVN Path first - svn_path = self._get_and_validate_svn_path("Edit .gitignore") - if not svn_path: - return # Stop if path is invalid - - # Construct the path to .gitignore - gitignore_path = os.path.join(svn_path, ".gitignore") - self.logger.debug(f"Target .gitignore path: {gitignore_path}") - - # Open the Editor Window - try: - # Create and run the modal editor window - editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) - self.logger.debug("Gitignore editor window opened.") - # Execution blocks here until the editor window is closed - - except Exception as e: - # Handle errors during editor creation/opening - self.logger.exception(f"Error opening .gitignore editor: {e}") - self.main_frame.show_error( - "Editor Error", - f"Could not open the .gitignore editor:\n{e}" - ) - - - # --- Core Functionality Methods --- - - def _get_and_validate_svn_path(self, operation_name="Operation"): - """Retrieves and validates the SVN path from the GUI.""" - # Check if main_frame and widget exist - if not hasattr(self, 'main_frame') or \ - not self.main_frame.winfo_exists() or \ - not hasattr(self.main_frame, 'svn_path_entry'): - self.logger.error(f"{operation_name}: GUI component unavailable.") - return None - - svn_path_str = self.main_frame.svn_path_entry.get().strip() - if not svn_path_str: - self.logger.error(f"{operation_name}: SVN Path is empty.") - self.main_frame.show_error("Input Error", "SVN Path cannot be empty.") - return None - - abs_path = os.path.abspath(svn_path_str) - if not os.path.isdir(abs_path): - self.logger.error( - f"{operation_name}: Invalid directory path: {abs_path}" - ) - self.main_frame.show_error( - "Input Error", - f"Invalid SVN path (not a directory):\n{abs_path}" - ) - return None - - self.logger.debug(f"{operation_name}: Validated SVN path: {abs_path}") - return abs_path - - - def _get_and_validate_usb_path(self, operation_name="Operation"): - """Retrieves and validates the USB/Bundle Target path from the GUI.""" - # Check if main_frame and widget exist - if not hasattr(self, 'main_frame') or \ - not self.main_frame.winfo_exists() or \ - not hasattr(self.main_frame, 'usb_path_entry'): - self.logger.error(f"{operation_name}: GUI component unavailable.") - return None - - usb_path_str = self.main_frame.usb_path_entry.get().strip() - if not usb_path_str: - self.logger.error(f"{operation_name}: Bundle Target Dir path empty.") - self.main_frame.show_error("Input Error", - "Bundle Target Directory cannot be empty.") - return None - - abs_path = os.path.abspath(usb_path_str) - if not os.path.isdir(abs_path): - self.logger.error( - f"{operation_name}: Invalid Bundle Target directory: {abs_path}" - ) - self.main_frame.show_error( - "Input Error", - f"Invalid Bundle Target path (not a directory):\n{abs_path}" - ) - return None - - self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}") - return abs_path - - - def prepare_svn_for_git(self): - """Handles the 'Prepare SVN for Git' action.""" - self.logger.info("--- Action: Prepare SVN Repo ---") - svn_path = self._get_and_validate_svn_path("Prepare SVN") - if not svn_path: - return # Validation failed - - # Save settings *before* potentially modifying .gitignore - if not self.save_profile_settings(): - self.logger.warning("Prepare SVN: Could not save profile settings.") - # Consider asking user if they want to continue - - # Check if already prepared to avoid redundant operations - git_dir_path = os.path.join(svn_path, ".git") - if os.path.exists(git_dir_path): - self.logger.info(f"Repository already prepared: {svn_path}") - self.main_frame.show_info("Already Prepared", - "Repository already prepared.") - self.update_svn_status_indicator(svn_path) # Ensure UI state correct - return - - # Execute Preparation command - self.logger.info(f"Executing preparation for: {svn_path}") - try: - self.git_commands.prepare_svn_for_git(svn_path) - self.logger.info("Repository prepared successfully.") - self.main_frame.show_info("Success", "Repository prepared.") - # Update indicator and dependent buttons - self.update_svn_status_indicator(svn_path) - except (GitCommandError, ValueError) as e: - # Handle known errors - self.logger.error(f"Error preparing repository: {e}") - self.main_frame.show_error("Preparation Error", f"Failed:\n{e}") - self.update_svn_status_indicator(svn_path) # Update state - except Exception as e: - # Handle unexpected errors - self.logger.exception(f"Unexpected error during preparation: {e}") - self.main_frame.show_error("Error", f"Unexpected error:\n{e}") - self.update_svn_status_indicator(svn_path) - - - def create_git_bundle(self): - """Handles the 'Create Bundle' action.""" - self.logger.info("--- Action: Create Git Bundle ---") - profile = self.main_frame.profile_var.get() - if not profile: - self.logger.error("Create Bundle: No profile selected.") - self.main_frame.show_error("Error", "No profile selected.") - return - - # Validate paths and bundle name - svn_path = self._get_and_validate_svn_path("Create Bundle") - if not svn_path: return - usb_path = self._get_and_validate_usb_path("Create Bundle") - if not usb_path: return - - bundle_name = self.main_frame.bundle_name_entry.get().strip() - if not bundle_name: - self.logger.error("Create Bundle: Bundle name is empty.") - self.main_frame.show_error("Input Error", "Bundle name is empty.") - return - # Ensure .bundle extension - if not bundle_name.lower().endswith(".bundle"): - self.logger.warning(f"Adding .bundle extension to '{bundle_name}'.") - bundle_name += ".bundle" - # Update the GUI field to reflect the change - self.main_frame.bundle_name_entry.delete(0, tk.END) - self.main_frame.bundle_name_entry.insert(0, bundle_name) - - bundle_full_path = os.path.join(usb_path, bundle_name) - self.logger.debug(f"Target bundle file: {bundle_full_path}") - - # Save current profile settings before proceeding - if not self.save_profile_settings(): - self.logger.warning("Create Bundle: Could not save settings.") - # Ask user? - - # --- Backup Step --- - if self.main_frame.autobackup_var.get(): - self.logger.info("Autobackup enabled. Starting backup...") - backup_success = self.create_backup(svn_path, profile) - if not backup_success: - self.logger.error("Aborted bundle creation: Backup failed.") - return # Stop if backup fails - - # --- Autocommit Step (if checkbox enabled) --- - if self.main_frame.autocommit_var.get(): - self.logger.info("Autocommit before bundle is enabled.") - try: - # Check for changes first - has_changes = self.git_commands.git_status_has_changes(svn_path) - if has_changes: - self.logger.info("Changes detected, performing autocommit...") - # Use message from Commit/Tag frame or default - custom_message = self.main_frame.commit_message_var.get().strip() - commit_msg = custom_message if custom_message else \ - f"Autocommit profile '{profile}' before bundle" - self.logger.debug(f"Using commit message: '{commit_msg}'") - # Perform the commit - commit_made = self.git_commands.git_commit(svn_path, commit_msg) - if commit_made: - self.logger.info("Autocommit successful.") - # else: git_commit logs 'nothing to commit' - else: - self.logger.info("No changes detected for autocommit.") - except (GitCommandError, ValueError) as e: - self.logger.error(f"Autocommit error: {e}") - self.main_frame.show_error("Autocommit Error", f"Failed:\n{e}") - return # Stop process if commit fails - except Exception as e: - self.logger.exception(f"Unexpected autocommit error: {e}") - self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}") - return - - # --- Create Bundle Step --- - self.logger.info(f"Creating bundle file: {bundle_full_path}") - try: - # Execute the bundle creation command - self.git_commands.create_git_bundle(svn_path, bundle_full_path) - # Verify bundle creation success (exists and not empty) - bundle_exists = os.path.exists(bundle_full_path) - bundle_not_empty = bundle_exists and os.path.getsize(bundle_full_path) > 0 - if bundle_exists and bundle_not_empty: - self.logger.info("Git bundle created successfully.") - self.main_frame.show_info("Success", - f"Bundle created:\n{bundle_full_path}") - else: - # Bundle likely empty or command had non-fatal warning - self.logger.warning("Bundle file not created or is empty.") - self.main_frame.show_warning( - "Bundle Not Created", - "Bundle empty or not created.\n(Likely no new commits)." - ) - # Clean up empty file if it exists - if bundle_exists and not bundle_not_empty: - try: - os.remove(bundle_full_path) - self.logger.info("Removed empty bundle file.") - except OSError: - self.logger.warning("Could not remove empty bundle file.") - - except (GitCommandError, ValueError) as e: - self.logger.error(f"Error creating Git bundle: {e}") - self.main_frame.show_error("Error", f"Failed create bundle:\n{e}") - except Exception as e: - self.logger.exception(f"Unexpected error during bundle creation: {e}") - self.main_frame.show_error("Error", f"Unexpected bundle error:\n{e}") - - - def fetch_from_git_bundle(self): - """Handles the 'Fetch from Bundle' action.""" - self.logger.info("--- Action: Fetch from Git Bundle ---") - profile = self.main_frame.profile_var.get() - if not profile: - self.logger.error("Fetch: No profile selected.") - self.main_frame.show_error("Error", "No profile selected.") - return - - # Validate paths and bundle name - svn_path = self._get_and_validate_svn_path("Fetch Bundle") - if not svn_path: return - usb_path = self._get_and_validate_usb_path("Fetch Bundle") - if not usb_path: return - bundle_name = self.main_frame.bundle_updated_name_entry.get().strip() - if not bundle_name: - self.logger.error("Fetch: Fetch bundle name empty.") - self.main_frame.show_error("Input Error", "Fetch bundle name empty.") - return - - bundle_full_path = os.path.join(usb_path, bundle_name) - self.logger.debug(f"Source bundle file: {bundle_full_path}") - - # Check if Bundle File Exists - if not os.path.isfile(bundle_full_path): - self.logger.error(f"Fetch: Bundle file not found: {bundle_full_path}") - self.main_frame.show_error( - "File Not Found", - f"Bundle file not found:\n{bundle_full_path}" - ) - return - - # Save settings before potentially changing repo state - if not self.save_profile_settings(): - self.logger.warning("Fetch: Could not save profile settings.") - # Ask user? - - # --- Backup Step --- - if self.main_frame.autobackup_var.get(): - self.logger.info("Autobackup enabled. Starting backup...") - backup_success = self.create_backup(svn_path, profile) - if not backup_success: - self.logger.error("Aborted fetch: Backup failed.") - return - - # --- Fetch and Merge Step --- - self.logger.info(f"Fetching into '{svn_path}' from: {bundle_full_path}") - try: - # Execute the fetch/merge command - self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) - self.logger.info("Fetch/merge process completed.") - # Inform user, acknowledging potential conflicts - self.main_frame.show_info( - "Fetch Complete", - f"Fetch complete.\nCheck logs for merge status/conflicts." - ) - - except GitCommandError as e: - # Handle specific errors like merge conflicts - self.logger.error(f"Error fetching/merging: {e}") - if "merge conflict" in str(e).lower(): - self.main_frame.show_error( - "Merge Conflict", - f"Merge conflict occurred.\nResolve manually in:\n{svn_path}\n" - f"Then run 'git add .' and 'git commit'." - ) - else: - # Show other Git command errors - self.main_frame.show_error("Fetch/Merge Error", f"Failed:\n{e}") - except ValueError as e: - # Handle validation errors passed up - self.logger.error(f"Validation error during fetch: {e}") - self.main_frame.show_error("Input Error", f"Invalid input:\n{e}") - except Exception as e: - # Handle unexpected errors - self.logger.exception(f"Unexpected error during fetch/merge: {e}") - self.main_frame.show_error("Error", f"Unexpected fetch error:\n{e}") - - - # --- Backup Logic --- - def _parse_exclusions(self, profile_name): - """Parses exclusion string from config into sets.""" - exclude_str = self.config_manager.get_profile_option( - profile_name, "backup_exclude_extensions", fallback="" - ) - excluded_extensions = set() - # Define standard directories to always exclude (lowercase for comparison) - excluded_dirs_base = {".git", ".svn"} - - if exclude_str: - # Split by comma, clean up each part - raw_extensions = exclude_str.split(',') - for ext in raw_extensions: - clean_ext = ext.strip().lower() - if clean_ext: - # Ensure extension starts with a dot - if not clean_ext.startswith('.'): - clean_ext = '.' + clean_ext - excluded_extensions.add(clean_ext) - - self.logger.debug( - f"Parsed Exclusions '{profile_name}' - " - f"Ext: {excluded_extensions}, Dirs: {excluded_dirs_base}" - ) - return excluded_extensions, excluded_dirs_base - - - def create_backup(self, source_repo_path, profile_name): - """Creates a timestamped ZIP backup, respecting profile exclusions.""" - self.logger.info( - f"Creating ZIP backup for '{profile_name}' from '{source_repo_path}'" - ) - - # Get and Validate Backup Destination Directory - backup_base_dir = self.main_frame.backup_dir_var.get().strip() - if not backup_base_dir: - self.logger.error("Backup Fail: Backup directory empty.") - self.main_frame.show_error("Backup Error", "Backup directory empty.") - return False - # Ensure directory exists, create if necessary - if not os.path.isdir(backup_base_dir): - self.logger.info(f"Creating backup dir: {backup_base_dir}") - try: - os.makedirs(backup_base_dir, exist_ok=True) - except OSError as e: - self.logger.error(f"Cannot create backup dir: {e}", exc_info=True) - self.main_frame.show_error("Backup Error", f"Cannot create dir:\n{e}") - return False - - # Parse Exclusions for the profile - try: - excluded_extensions, excluded_dirs_base = self._parse_exclusions(profile_name) - except Exception as e: - self.logger.error(f"Failed parse exclusions: {e}", exc_info=True) - self.main_frame.show_error("Backup Error", "Cannot parse exclusions.") - return False - - # Construct Backup Filename - now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - # Sanitize profile name for use in filename - safe_profile = "".join(c for c in profile_name - if c.isalnum() or c in '_-').rstrip() or "profile" - backup_filename = f"{now_str}_backup_{safe_profile}.zip" - backup_full_path = os.path.join(backup_base_dir, backup_filename) - self.logger.info(f"Target backup ZIP file: {backup_full_path}") - - # Create ZIP Archive - files_added = 0 - files_excluded = 0 - dirs_excluded = 0 - zip_f = None # Initialize zip file object - try: - # Open ZIP file with appropriate settings - zip_f = zipfile.ZipFile(backup_full_path, 'w', - compression=zipfile.ZIP_DEFLATED, - allowZip64=True) # Support large archives - - # Walk through the source directory - for root, dirs, files in os.walk(source_repo_path, topdown=True): - # --- Directory Exclusion --- - original_dirs = list(dirs) # Copy before modifying - # Exclude based on base name (case-insensitive) - dirs[:] = [d for d in dirs if d.lower() not in excluded_dirs_base] - # Log excluded directories for this level - excluded_dirs_now = set(original_dirs) - set(dirs) - if excluded_dirs_now: - dirs_excluded += len(excluded_dirs_now) - for ex_dir in excluded_dirs_now: - path_excluded = os.path.join(root, ex_dir) - self.logger.debug(f"Excluding directory: {path_excluded}") - - # --- File Exclusion and Addition --- - for filename in files: - # Get file extension (lowercase for comparison) - _, file_ext = os.path.splitext(filename) - file_ext_lower = file_ext.lower() - - # Check exclusion rules (filename matches dir OR extension matches) - # Case-insensitive check for filename matching excluded dirs - if filename.lower() in excluded_dirs_base or \ - file_ext_lower in excluded_extensions: - path_excluded = os.path.join(root, filename) - self.logger.debug(f"Excluding file: {path_excluded}") - files_excluded += 1 - continue # Skip this file - - # If not excluded, add file to ZIP - file_full_path = os.path.join(root, filename) - # Store with relative path inside ZIP archive - archive_name = os.path.relpath(file_full_path, source_repo_path) - - try: - zip_f.write(file_full_path, arcname=archive_name) - files_added += 1 - # Log progress occasionally for large backups - if files_added % 500 == 0: - self.logger.debug(f"Added {files_added} files...") - except Exception as write_e: - # Log error writing specific file but continue backup process - self.logger.error( - f"Error writing file '{file_full_path}' to ZIP: {write_e}", - exc_info=True - ) - - # Log final summary after successful walk and write attempts - self.logger.info(f"Backup ZIP creation finished: {backup_full_path}") - self.logger.info( - f"Summary - Added: {files_added}, Excl Files: {files_excluded}, " - f"Excl Dirs: {dirs_excluded}" - ) - return True # Indicate backup process completed - - except OSError as e: - # Handle OS-level errors (permissions, disk space, etc.) - self.logger.error(f"OS error during backup ZIP creation: {e}", - exc_info=True) - self.main_frame.show_error("Backup Error", f"OS Error creating ZIP:\n{e}") - return False - except zipfile.BadZipFile as e: - # Handle errors related to the ZIP file format itself - self.logger.error(f"ZIP format error during backup: {e}", - exc_info=True) - self.main_frame.show_error("Backup Error", f"ZIP format error:\n{e}") - return False - except Exception as e: - # Catch any other unexpected error during the process - self.logger.exception(f"Unexpected error during ZIP backup: {e}") - self.main_frame.show_error("Backup Error", - f"Unexpected ZIP error:\n{e}") - return False - finally: - # Ensure the ZIP file is always closed, even if errors occurred - if zip_f: - zip_f.close() - self.logger.debug(f"ZIP file '{backup_full_path}' closed.") - # Clean up potentially empty/failed ZIP file - # Check if file exists and if any files were actually added - if os.path.exists(backup_full_path) and files_added == 0: - self.logger.warning(f"Backup ZIP is empty: {backup_full_path}") - try: - # Attempt to remove the empty zip file - os.remove(backup_full_path) - self.logger.info("Removed empty backup ZIP file.") - except OSError as rm_e: - # Log error if removal fails - self.logger.error(f"Failed remove empty backup ZIP: {rm_e}") - - - def manual_backup(self): - """Handles the 'Backup Now' button click.""" - self.logger.info("--- Action: Manual Backup Now ---") - profile = self.main_frame.profile_var.get() - if not profile: - self.logger.warning("Manual Backup: No profile selected.") - self.main_frame.show_error("Backup Error", "No profile selected.") - return - - # Validate SVN Path - svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") - if not svn_path: - return # Validation failed - - # Save current settings first (especially backup dir and exclusions) - self.logger.info("Saving settings before manual backup...") - if not self.save_profile_settings(): - self.logger.error("Manual Backup: Could not save settings.") - # Ask user if they want to continue with potentially old settings - if not self.main_frame.ask_yes_no( - "Save Error", - "Could not save current settings (e.g., exclusions).\n" - "Continue backup with previously saved settings?" - ): - self.logger.warning("Manual backup aborted by user.") - return - - # Call the create_backup method (which handles ZIP creation) - self.logger.info(f"Starting manual backup for profile '{profile}'...") - success = self.create_backup(svn_path, profile) - - # Show result message to the user - if success: - self.main_frame.show_info("Backup Complete", - "Manual ZIP backup completed successfully.") - else: - # Error message should have been shown by create_backup - self.logger.error(f"Manual backup failed for profile '{profile}'.") - # Optionally show a redundant message here if needed - - - # --- Tag Management Methods --- - - def refresh_tag_list(self): - """Fetches tags with subjects and updates the GUI listbox.""" - self.logger.info("--- Action: Refresh Tag List ---") - svn_path = self._get_and_validate_svn_path("Refresh Tags") - if not svn_path: - # Clear list if path invalid - if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) - return - - # Check if repository is prepared (Git commands require .git) - git_dir_path = os.path.join(svn_path, ".git") - if not os.path.exists(git_dir_path): - self.logger.warning("Cannot refresh tags: Repository not prepared.") - # Clear list and potentially show warning? UI state should reflect. - if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) - return - - # Fetch tags and update GUI - try: - # list_tags now returns list of tuples (name, subject) - tags_data = self.git_commands.list_tags(svn_path) - if hasattr(self, 'main_frame'): - self.main_frame.update_tag_list(tags_data) - self.logger.info(f"Tag list updated ({len(tags_data)} tags found).") - except Exception as e: - # Catch potential errors from list_tags or GUI update - self.logger.error(f"Failed to retrieve or update tag list: {e}", - exc_info=True) - self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") - if hasattr(self, 'main_frame'): - # Clear list on error - self.main_frame.update_tag_list([]) - - - def create_tag(self): - """Handles 'Create Tag': commits (if needed), shows dialog, creates tag.""" - self.logger.info("--- Action: Create Tag ---") - svn_path = self._get_and_validate_svn_path("Create Tag") - if not svn_path: return - - profile = self.main_frame.profile_var.get() - if not profile: - self.logger.error("Create Tag: No profile selected.") - self.main_frame.show_error("Error", "No profile selected.") - return - - # Save current settings (especially commit message) before proceeding - if not self.save_profile_settings(): - self.logger.warning("Create Tag: Could not save settings first.") - # Ask user? - - # --- Step 1: Check for changes and commit IF commit message provided --- - try: - has_changes = self.git_commands.git_status_has_changes(svn_path) - if has_changes: - self.logger.info("Uncommitted changes detected before tagging.") - # Get commit message from the dedicated GUI field - commit_msg = self.main_frame.commit_message_var.get().strip() - if not commit_msg: - # Block tag creation if changes exist but no message - self.logger.error( - "Create Tag blocked: Changes exist but no commit message." - ) - self.main_frame.show_error( - "Commit Required", - "Uncommitted changes exist.\nPlease enter a commit message " - "in the 'Commit / Tag Management' section and try again." - ) - return # Stop the process - - # Confirm commit with the user using the provided message - confirm_commit_msg = ( - f"Commit current changes with message:\n'{commit_msg}'?" - ) - if not self.main_frame.ask_yes_no("Confirm Commit", - confirm_commit_msg): - self.logger.info("User cancelled commit before tagging.") - self.main_frame.show_warning("Cancelled", - "Tag creation cancelled.") - return # Stop tagging - - # Perform the commit - commit_made = self.git_commands.git_commit(svn_path, commit_msg) - if commit_made: - self.logger.info("Pre-tag commit successful.") - # Clear the commit message box after successful commit? - # self.main_frame.commit_message_var.set("") - # else: git_commit logs 'nothing to commit', proceed anyway - - else: - # No changes, proceed directly to getting tag info - self.logger.info("No uncommitted changes detected before tagging.") - - except (GitCommandError, ValueError) as e: - # Handle errors during status check or commit - self.logger.error(f"Error committing before tag: {e}") - self.main_frame.show_error("Commit Error", f"Failed commit:\n{e}") - return # Stop if commit failed - except Exception as e: - # Handle unexpected errors - self.logger.exception(f"Unexpected pre-tag commit error: {e}") - self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}") - return - - # --- Step 2: Open Dialog for Tag Name and Tag Message --- - self.logger.debug("Opening create tag dialog...") - # Use the custom dialog from gui.py - dialog = CreateTagDialog(self.master) # Parent is the main Tk window - tag_info = dialog.result # Returns tuple (name, message) or None - - # --- Step 3: Create Tag if user confirmed dialog --- - if tag_info: - tag_name, tag_message = tag_info - self.logger.info(f"User wants tag: '{tag_name}', msg: '{tag_message}'") - try: - # Execute tag creation command - self.git_commands.create_tag(svn_path, tag_name, tag_message) - self.logger.info(f"Tag '{tag_name}' created successfully.") - self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") - # Refresh list to show the new tag - self.refresh_tag_list() - except (GitCommandError, ValueError) as e: - # Handle known errors (tag exists, invalid name) - self.logger.error(f"Failed create tag '{tag_name}': {e}") - self.main_frame.show_error("Tag Error", f"Could not create tag:\n{e}") - except Exception as e: - # Handle unexpected errors during tag creation - self.logger.exception(f"Unexpected error creating tag: {e}") - self.main_frame.show_error("Error", f"Unexpected tag error:\n{e}") - else: - # User cancelled the tag input dialog - self.logger.info("Tag creation cancelled by user in dialog.") - - - def checkout_tag(self): - """Handles the 'Checkout Selected Tag' action.""" - self.logger.info("--- Action: Checkout Tag ---") - svn_path = self._get_and_validate_svn_path("Checkout Tag") - if not svn_path: return - - # Get selected tag name from the listbox - selected_tag = self.main_frame.get_selected_tag() # Gets only the name - if not selected_tag: - self.logger.warning("Checkout Tag: No tag selected.") - self.main_frame.show_error("Selection Error", "Select a tag.") - return - - self.logger.info(f"Attempting checkout for tag: {selected_tag}") - - # CRITICAL CHECK: Ensure no uncommitted changes before checkout - try: - has_changes = self.git_commands.git_status_has_changes(svn_path) - if has_changes: - self.logger.error("Checkout blocked: Uncommitted changes exist.") - self.main_frame.show_error( - "Checkout Blocked", - "Uncommitted changes exist.\nCommit or stash first." - ) - return # Prevent checkout - self.logger.debug("No uncommitted changes found.") - except (GitCommandError, ValueError) as e: - # Handle errors during status check - self.logger.error(f"Status check error before checkout: {e}") - self.main_frame.show_error("Status Error", f"Cannot check status:\n{e}") - return - except Exception as e: - # Handle unexpected errors during status check - self.logger.exception(f"Unexpected status check error: {e}") - self.main_frame.show_error("Error", f"Unexpected status error:\n{e}") - return - - # CONFIRMATION dialog with warnings - confirm_msg = ( - f"Checkout tag '{selected_tag}'?\n\n" - f"WARNINGS:\n" - f"- Files WILL BE OVERWRITTEN.\n" - f"- NO backup created.\n" - f"- Enters 'detached HEAD' state." - ) - if not self.main_frame.ask_yes_no("Confirm Checkout", confirm_msg): - self.logger.info("Tag checkout cancelled by user.") - return - - # Proceed with checkout after confirmation - self.logger.info(f"User confirmed checkout for tag: {selected_tag}") - # Save profile settings before potentially changing repo state? Optional. - if not self.save_profile_settings(): - self.logger.warning("Checkout Tag: Could not save profile settings.") - # Decide whether to stop or proceed - - try: - # Execute checkout command - checkout_success = self.git_commands.checkout_tag(svn_path, selected_tag) - if checkout_success: - self.logger.info(f"Tag '{selected_tag}' checked out.") - self.main_frame.show_info( - "Checkout Successful", - f"Checked out tag '{selected_tag}'.\n\n" - f"NOTE: In 'detached HEAD' state.\n" - f"Use 'git switch -' or checkout branch." - ) - # TODO: Consider updating UI state to reflect detached HEAD? - # e.g., disable 'Create Tag' button? - - except (GitCommandError, ValueError) as e: - # Handle known errors like tag not found - self.logger.error(f"Failed checkout tag '{selected_tag}': {e}") - self.main_frame.show_error("Checkout Error", f"Could not checkout:\n{e}") - except Exception as e: - # Handle unexpected errors during checkout - self.logger.exception(f"Unexpected checkout error: {e}") - self.main_frame.show_error("Error", f"Unexpected checkout error:\n{e}") - - - # --- GUI State Utilities --- - def _clear_and_disable_fields(self): - """Clears relevant GUI fields and disables most buttons.""" - if hasattr(self, 'main_frame'): - mf = self.main_frame # Alias - # Clear Repository frame fields - mf.svn_path_entry.delete(0, tk.END) - mf.usb_path_entry.delete(0, tk.END) - mf.bundle_name_entry.delete(0, tk.END) - mf.bundle_updated_name_entry.delete(0, tk.END) - # Clear Commit/Tag frame fields - mf.commit_message_var.set("") - mf.autocommit_var.set(False) - mf.update_tag_list([]) # Clear tag listbox - # Backup frame fields might retain values or load defaults - - # Reset SVN indicator and dependent buttons - # This handles Prepare, EditGitignore, Commit/Tag widget states - self.update_svn_status_indicator("") - # Disable general action buttons separately - self._disable_general_buttons() - self.logger.debug("GUI fields cleared/reset. Buttons disabled.") - - - def _disable_general_buttons(self): - """Disables buttons generally requiring only a loaded profile.""" - if hasattr(self, 'main_frame'): - # List of general action button attribute names - button_names = [ - 'create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button' - ] - # Iterate and disable if the button exists - for name in button_names: - button = getattr(self.main_frame, name, None) - if button: - button.config(state=tk.DISABLED) - - - def _enable_function_buttons(self): - """ - Enables general action buttons. State-dependent buttons are handled - by update_svn_status_indicator. - """ - if hasattr(self, 'main_frame'): - general_state = tk.NORMAL - # List of general action button attribute names - button_names = [ - 'create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button' - ] - # Iterate and enable if the button exists - for name in button_names: - button = getattr(self.main_frame, name, None) - if button: - button.config(state=general_state) - - # Ensure state-dependent buttons reflect the current status - # This call updates Prepare, EditGitignore, Commit/Tag widget states - current_svn_path = self.main_frame.svn_path_entry.get() - self.update_svn_status_indicator(current_svn_path) - self.logger.debug("General buttons enabled. State buttons updated.") - - - def show_fatal_error(self, message): - """Shows a fatal error message before the app potentially exits.""" - try: - # Determine parent window safely - parent = None - if hasattr(self, 'master') and self.master and self.master.winfo_exists(): - parent = self.master - messagebox.showerror("Fatal Error", message, parent=parent) - except tk.TclError: - # Fallback if GUI is not ready or fails during error display - print(f"FATAL ERROR: {message}") - except Exception as e: - # Log error showing the message box itself - print(f"FATAL ERROR (and GUI error: {e}): {message}") - -# --- Application Entry Point --- -def main(): - """Main function: Creates Tkinter root and runs the application.""" - root = tk.Tk() - # Adjust min size for the new layout - root.minsize(700, 700) # Increased height needed for Commit/Tag area - app = None # Initialize app variable - try: - app = GitSvnSyncApp(root) - # Start main loop only if initialization likely succeeded - # A more robust check might involve a flag set at the end of __init__ - if hasattr(app, 'main_frame') and app.main_frame: - root.mainloop() - else: - # Initialization failed before GUI setup could complete - print("Application initialization failed, exiting.") - # Ensure window closes if init failed but window was created - if root and root.winfo_exists(): - root.destroy() - except Exception as e: - # Catch-all for unexpected errors during startup or main loop - logging.exception("Fatal error during application startup or main loop.") - # Try showing message box, fallback to print - try: - parent_window = root if root and root.winfo_exists() else None - messagebox.showerror("Fatal Error", - f"Application failed unexpectedly:\n{e}", - parent=parent_window) - except Exception as msg_e: - # Log error related to showing the message box - print(f"FATAL ERROR (GUI error: {msg_e}): App failed:\n{e}") - finally: - # Log application exit regardless of success or failure - logging.info("Application exiting.") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/GitUtility.ico b/GitUtility.ico new file mode 100644 index 0000000000000000000000000000000000000000..65df31843805993bf94c148e0ca6d372a635f211 GIT binary patch literal 81643 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 literal 0 HcmV?d00001 diff --git a/GitUtility.py b/GitUtility.py index ca8b97c..c1abdec 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -1,10 +1,12 @@ # GitUtility.py import os +import sys +# import shutil # Not needed anymore import datetime import tkinter as tk from tkinter import messagebox import logging -import zipfile +# import zipfile # Not needed anymore # Import application modules from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR @@ -63,7 +65,7 @@ class GitSvnSyncApp: self.main_frame = MainFrame( master, load_profile_settings_cb=self.load_profile_settings, - browse_folder_cb=self.browse_folder, + # browse_folder_cb REMOVED - Handled within MainFrame now update_svn_status_cb=self.update_svn_status_indicator, # Core Action Callbacks prepare_svn_for_git_cb=self.ui_prepare_svn, @@ -132,10 +134,8 @@ class GitSvnSyncApp: return None if not hasattr(self.main_frame, 'svn_path_entry'): self.logger.error(f"{operation_name}: SVN path widget missing.") - # Try showing error if main_frame exists, otherwise just log if hasattr(self, 'main_frame'): - self.main_frame.show_error("Internal Error", - "SVN Path widget not found.") + self.main_frame.show_error("Internal Error", "SVN Path widget missing.") return None svn_path_str = self.main_frame.svn_path_entry.get() @@ -147,13 +147,8 @@ class GitSvnSyncApp: abs_path = os.path.abspath(svn_path_str) if not os.path.isdir(abs_path): - self.logger.error( - f"{operation_name}: Invalid directory path: {abs_path}" - ) - self.main_frame.show_error( - "Input Error", - f"Invalid SVN path (not a directory):\n{abs_path}" - ) + self.logger.error(f"{operation_name}: Invalid directory path: {abs_path}") + self.main_frame.show_error("Input Error", f"Invalid SVN path:\n{abs_path}") return None self.logger.debug(f"{operation_name}: Validated SVN path: {abs_path}") @@ -168,32 +163,72 @@ class GitSvnSyncApp: if not hasattr(self.main_frame, 'usb_path_entry'): self.logger.error(f"{operation_name}: USB path widget missing.") if hasattr(self, 'main_frame'): - self.main_frame.show_error("Internal Error", - "USB Path widget not found.") + self.main_frame.show_error("Internal Error", "USB Path widget missing.") return None usb_path_str = self.main_frame.usb_path_entry.get() usb_path_str = usb_path_str.strip() if not usb_path_str: self.logger.error(f"{operation_name}: Bundle Target Dir path empty.") - self.main_frame.show_error("Input Error", - "Bundle Target Directory cannot be empty.") + self.main_frame.show_error("Input Error", "Bundle Target Dir empty.") return None abs_path = os.path.abspath(usb_path_str) if not os.path.isdir(abs_path): - self.logger.error( - f"{operation_name}: Invalid Bundle Target directory: {abs_path}" - ) - self.main_frame.show_error( - "Input Error", - f"Invalid Bundle Target path (not a directory):\n{abs_path}" - ) + self.logger.error(f"{operation_name}: Invalid Bundle Target: {abs_path}") + self.main_frame.show_error("Input Error", f"Invalid Bundle Target:\n{abs_path}") return None self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}") return abs_path + # --- ADDED: Helper to parse exclusions directly here --- + def _parse_exclusions(self, profile_name): + """ + Parses exclusion string from config for the given profile. + Needed here because ActionHandler might not have direct ConfigManager access, + and exclusions are needed for backup steps within actions. + + Args: + profile_name (str): The name of the profile. + + Returns: + tuple: (set of excluded extensions (lowercase, starting with '.'), + set of excluded base directory names (lowercase)) + Raises: + ValueError: If exclusion string parsing fails. + """ + try: + # Get exclusion string from config + exclude_str = self.config_manager.get_profile_option( + profile_name, "backup_exclude_extensions", fallback="" + ) + excluded_extensions = set() + # Define standard directories to always exclude (lowercase for comparison) + excluded_dirs_base = {".git", ".svn"} + + if exclude_str: + raw_extensions = exclude_str.split(',') + for ext in raw_extensions: + clean_ext = ext.strip().lower() + if not clean_ext: + continue # Skip empty parts + # Ensure extension starts with a dot + if not clean_ext.startswith('.'): + clean_ext = '.' + clean_ext + excluded_extensions.add(clean_ext) + + self.logger.debug( + f"Parsed Exclusions '{profile_name}' - " + f"Ext: {excluded_extensions}, Dirs: {excluded_dirs_base}" + ) + return excluded_extensions, excluded_dirs_base + except Exception as e: + self.logger.error(f"Error parsing exclusions for '{profile_name}': {e}", + exc_info=True) + # Raise a specific error to indicate parsing failure + raise ValueError(f"Could not parse backup exclusions: {e}") from e + # --- Profile Handling Wrappers --- def load_profile_settings(self, profile_name): @@ -207,15 +242,14 @@ class GitSvnSyncApp: profile_data = self.profile_handler.load_profile_data(profile_name) if not profile_data: # Handler logs error, show message and clear UI - self.main_frame.show_error("Load Error", - f"Could not load profile '{profile_name}'.") + self.main_frame.show_error("Load Error", f"Could not load '{profile_name}'.") self._clear_and_disable_fields() return # Update GUI fields with loaded data if frame exists if hasattr(self, 'main_frame'): mf = self.main_frame # Alias - # Update Repository Frame widgets + # Update all widgets based on loaded data mf.svn_path_entry.delete(0, tk.END) mf.svn_path_entry.insert(0, profile_data.get("svn_working_copy_path", "")) mf.usb_path_entry.delete(0, tk.END) @@ -224,10 +258,8 @@ class GitSvnSyncApp: mf.bundle_name_entry.insert(0, profile_data.get("bundle_name", "")) mf.bundle_updated_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.insert(0, profile_data.get("bundle_name_updated", "")) - # Update Commit/Tag Frame widgets mf.autocommit_var.set(profile_data.get("autocommit", False)) # Bool mf.commit_message_var.set(profile_data.get("commit_message", "")) - # Update Backup Frame widgets mf.autobackup_var.set(profile_data.get("autobackup", False)) # Bool mf.backup_dir_var.set(profile_data.get("backup_dir", DEFAULT_BACKUP_DIR)) mf.backup_exclude_extensions_var.set( @@ -264,24 +296,24 @@ class GitSvnSyncApp: profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Save Error", "No profile selected.") - return # Don't proceed + return False # Indicate failure to caller if needed # Gather data from GUI current_data = self._get_data_from_gui() if current_data is None: # Check if reading GUI failed self.main_frame.show_error("Internal Error", "Could not read GUI data.") - return + return False # Delegate saving to ProfileHandler success = self.profile_handler.save_profile_data(profile, current_data) if success: - # Give positive feedback self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.") + return True else: # Error message likely shown by handler/save method - self.logger.error(f"Saving settings failed for profile '{profile}'.") - # self.main_frame.show_error("Save Error", "Failed to save settings.") + # self.main_frame.show_error("Save Error", f"Failed save settings.") + return False def _get_data_from_gui(self): @@ -292,17 +324,16 @@ class GitSvnSyncApp: mf = self.main_frame # Read values from all relevant widgets/variables - data = { - "svn_working_copy_path": mf.svn_path_entry.get(), - "usb_drive_path": mf.usb_path_entry.get(), - "bundle_name": mf.bundle_name_entry.get(), - "bundle_name_updated": mf.bundle_updated_name_entry.get(), - "autocommit": mf.autocommit_var.get(), # Gets boolean - "commit_message": mf.commit_message_var.get(), - "autobackup": mf.autobackup_var.get(), # Gets boolean - "backup_dir": mf.backup_dir_var.get(), - "backup_exclude_extensions": mf.backup_exclude_extensions_var.get() - } + data = {} + data["svn_working_copy_path"] = mf.svn_path_entry.get() + data["usb_drive_path"] = mf.usb_path_entry.get() + data["bundle_name"] = mf.bundle_name_entry.get() + data["bundle_name_updated"] = mf.bundle_updated_name_entry.get() + data["autocommit"] = mf.autocommit_var.get() # Gets boolean + data["commit_message"] = mf.commit_message_var.get() + data["autobackup"] = mf.autobackup_var.get() # Gets boolean + data["backup_dir"] = mf.backup_dir_var.get() + data["backup_exclude_extensions"] = mf.backup_exclude_extensions_var.get() return data @@ -315,19 +346,16 @@ class GitSvnSyncApp: new_name = new_name.strip() if not new_name: - self.main_frame.show_error("Error", "Profile name cannot be empty."); return + self.main_frame.show_error("Error", "Profile name empty."); return # Delegate adding logic success = self.profile_handler.add_new_profile(new_name) - if success: - # Update GUI dropdown and select the new profile sections = self.profile_handler.get_profile_list() self.main_frame.update_profile_dropdown(sections) self.main_frame.profile_var.set(new_name) # Triggers load self.main_frame.show_info("Profile Added", f"Profile '{new_name}' created.") else: - # Handler logged the reason (exists or error) self.main_frame.show_error("Error", f"Could not add profile '{new_name}'.") @@ -350,10 +378,9 @@ class GitSvnSyncApp: self.main_frame.update_profile_dropdown(sections) # Triggers load self.main_frame.show_info("Removed", f"Profile '{profile}' removed.") else: - # Handler logged the reason self.main_frame.show_error("Error", f"Failed to remove '{profile}'.") else: - self.logger.info("Profile removal cancelled.") + self.logger.info("Removal cancelled.") # --- GUI Interaction Wrappers --- @@ -369,7 +396,7 @@ class GitSvnSyncApp: self.logger.debug(f"Selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) - # Update status if SVN path changed + # Trigger status update if SVN path changed if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: @@ -385,10 +412,10 @@ class GitSvnSyncApp: if hasattr(self, 'main_frame'): mf = self.main_frame # Alias - # Update indicator & Prepare button via MainFrame method + # Update indicator & Prepare button via GUI method mf.update_svn_indicator(is_ready) - # Determine states for other widgets based on validity/readiness + # Determine states for other dependent widgets gitignore_state = tk.NORMAL if is_valid else tk.DISABLED repo_ready_state = tk.NORMAL if is_ready else tk.DISABLED @@ -425,7 +452,7 @@ class GitSvnSyncApp: """Opens the modal editor window for .gitignore.""" self.logger.info("--- Action Triggered: Edit .gitignore ---") svn_path = self._get_and_validate_svn_path("Edit .gitignore") - if not svn_path: return # Stop if path invalid + if not svn_path: return gitignore_path = os.path.join(svn_path, ".gitignore") self.logger.debug(f"Target .gitignore path: {gitignore_path}") @@ -449,9 +476,12 @@ class GitSvnSyncApp: if not svn_path: return # Save settings before action + # Use ui_save_settings which returns True/False if not self.ui_save_settings(): - self.logger.warning("Prepare SVN: Failed save settings first.") - # Ask user? + self.logger.warning("Prepare SVN: Failed to save settings first.") + # Ask user if they want to continue? + # if not self.main_frame.ask_yes_no("Warning", "Could not save settings.\nContinue anyway?"): + # return # Delegate execution to ActionHandler try: @@ -491,7 +521,7 @@ class GitSvnSyncApp: # Ensure .bundle extension if not bundle_name.lower().endswith(".bundle"): bundle_name += ".bundle" - mf = self.main_frame + mf = self.main_frame # Alias mf.bundle_name_entry.delete(0, tk.END) mf.bundle_name_entry.insert(0, bundle_name) bundle_full_path = os.path.join(usb_path, bundle_name) @@ -669,24 +699,26 @@ class GitSvnSyncApp: # Get commit message from the GUI entry commit_msg = self.main_frame.commit_message_var.get().strip() if not commit_msg: - self.main_frame.show_error("Commit Error", "Commit message cannot be empty.") + self.main_frame.show_error("Commit Error", "Commit message empty.") return - # Save settings first? Optional, but saves the message if typed. + # Save settings first? Optional, but saves the message if user typed it. if not self.ui_save_settings(): self.logger.warning("Manual Commit: Could not save settings.") - # Ask user if they want to continue? + # Ask user? # Delegate commit execution to ActionHandler try: - commit_made = self.action_handler.execute_manual_commit(svn_path, commit_msg) + commit_made = self.action_handler.execute_manual_commit( + svn_path, commit_msg + ) if commit_made: self.main_frame.show_info("Success", "Changes committed.") - # Optionally clear message field after successful commit + # Clear message field after successful commit? Optional. # self.main_frame.commit_message_var.set("") else: # git_commit already logged "nothing to commit" - self.main_frame.show_info("Info", "No changes were detected to commit.") + self.main_frame.show_info("Info", "No changes to commit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Manual commit failed: {e}") @@ -708,10 +740,10 @@ class GitSvnSyncApp: self.main_frame.update_tag_list([]) # Clear list return + # Fetch tags and update GUI try: - # Get tag data (list of tuples) from GitCommands + # list_tags returns list of tuples (name, subject) tags_data = self.git_commands.list_tags(svn_path) - # Update the GUI listbox if hasattr(self, 'main_frame'): self.main_frame.update_tag_list(tags_data) self.logger.info(f"Tag list updated ({len(tags_data)} tags).") @@ -729,9 +761,10 @@ class GitSvnSyncApp: if not svn_path: return profile = self.main_frame.profile_var.get() if not profile: - self.main_frame.show_error("Error", "No profile selected."); return + self.main_frame.show_error("Error", "No profile selected.") + return - # Get commit message from GUI (for potential pre-commit) + # Get commit message from GUI (needed by action handler for pre-commit) commit_msg = self.main_frame.commit_message_var.get().strip() # Save settings before action (saves commit message) @@ -739,32 +772,40 @@ class GitSvnSyncApp: self.logger.warning("Create Tag: Could not save settings first.") # Ask user? - # Open Dialog first to get Tag Name and Tag Message + # --- Open Dialog to get Tag Name and Tag Message --- self.logger.debug("Opening create tag dialog...") - dialog = CreateTagDialog(self.master) + dialog = CreateTagDialog(self.master) # Parent is the main Tk window tag_info = dialog.result # Returns (tag_name, tag_message) or None + if not tag_info: - self.logger.info("Tag creation cancelled in dialog."); return + # User cancelled the dialog + self.logger.info("Tag creation cancelled by user in dialog.") + return tag_name, tag_message = tag_info self.logger.info(f"User provided tag: '{tag_name}', msg: '{tag_message}'") - # Delegate Execution (including potential pre-commit) to ActionHandler + # --- Delegate Execution to ActionHandler --- try: + # ActionHandler manages pre-commit logic based on commit_msg presence success = self.action_handler.execute_create_tag( svn_path, commit_msg, tag_name, tag_message ) - if success: + # execute_create_tag raises exceptions on failure + if success: # Should be true if no exception self.logger.info(f"Tag '{tag_name}' created successfully.") self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") - self.refresh_tag_list() # Update list - except ValueError as e: # Catch specific errors like "commit message required" + self.refresh_tag_list() # Update list after successful creation + except ValueError as e: + # Catch specific errors like "commit message required" self.logger.error(f"Tag creation validation failed: {e}") self.main_frame.show_error("Tag Error", str(e)) - except GitCommandError as e: # Catch Git command errors (commit or tag) + except GitCommandError as e: + # Catch Git command errors (from commit or tag) self.logger.error(f"Tag creation failed (Git Error): {e}") self.main_frame.show_error("Tag Error", f"Git command failed:\n{e}") - except Exception as e: # Catch unexpected errors + except Exception as e: + # Catch unexpected errors self.logger.exception(f"Unexpected error creating tag: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") @@ -777,7 +818,8 @@ class GitSvnSyncApp: selected_tag = self.main_frame.get_selected_tag() # Gets name if not selected_tag: - self.main_frame.show_error("Selection Error", "Select a tag."); return + self.main_frame.show_error("Selection Error", "Select a tag.") + return self.logger.info(f"Attempting checkout for tag: {selected_tag}") @@ -790,7 +832,7 @@ class GitSvnSyncApp: # Save settings before action? Optional. if not self.ui_save_settings(): - self.logger.warning("Checkout Tag: Could not save settings.") + self.logger.warning("Checkout Tag: Could not save profile settings.") # Delegate execution to ActionHandler try: @@ -798,7 +840,7 @@ class GitSvnSyncApp: if success: self.main_frame.show_info("Success", f"Checked out tag '{selected_tag}'.\n\nNOTE: In 'detached HEAD'.") - # Update display after successful checkout + # Update branch display after successful checkout self.update_current_branch_display() except ValueError as e: # Catch specific errors like "uncommitted changes" self.main_frame.show_error("Checkout Blocked", str(e)) @@ -820,7 +862,7 @@ class GitSvnSyncApp: self.main_frame.show_error("Selection Error", "Select a tag to delete.") return - # Confirmation + # Confirmation dialog msg = f"Delete tag '{selected_tag}' permanently?\nCannot be easily undone." if not self.main_frame.ask_yes_no("Confirm Delete Tag", msg): self.logger.info("Tag deletion cancelled.") @@ -856,8 +898,10 @@ class GitSvnSyncApp: # Fetch and update GUI try: + # Assumes git_commands method exists and returns list of names branches = self.git_commands.list_branches(svn_path) if hasattr(self, 'main_frame'): + # Update listbox and potentially current branch display implicitly self.main_frame.update_branch_list(branches) self.logger.info(f"Branch list updated ({len(branches)} branches).") except Exception as e: @@ -871,22 +915,25 @@ class GitSvnSyncApp: """Gets the current branch and updates the display label.""" self.logger.debug("Updating current branch display...") svn_path = self._get_and_validate_svn_path("Update Branch Display") - current_branch_name = "" # Default + current_branch_name = "" # Default value # Only query git if repo is ready if svn_path and os.path.exists(os.path.join(svn_path, ".git")): try: + # Use GitCommands to get branch name branch_name = self.git_commands.get_current_branch(svn_path) - # Handle return values from get_current_branch + # Update display text based on result if branch_name == "(DETACHED HEAD)": - current_branch_name = branch_name + current_branch_name = branch_name # Show detached state clearly elif branch_name == "": - current_branch_name = "" + current_branch_name = "" # Show error state elif branch_name: - current_branch_name = branch_name - else: # Should not happen if logic in get_current_branch is correct + current_branch_name = branch_name # Show actual branch name + else: + # Fallback if method returns None unexpectedly current_branch_name = "" except Exception as e: + # Handle exceptions during git command execution self.logger.error(f"Failed to get current branch: {e}") current_branch_name = "" @@ -912,18 +959,15 @@ class GitSvnSyncApp: # Delegate execution to ActionHandler try: - # TODO: Add start_point logic if needed later + # Assuming ActionHandler has execute_create_branch + # TODO: Add start_point logic later if needed success = self.action_handler.execute_create_branch( svn_path, new_branch_name ) if success: self.main_frame.show_info("Success", f"Branch '{new_branch_name}' created.") self.refresh_branch_list() # Update list - # Ask user if they want to switch? - # switch_q = f"Switch to new branch '{new_branch_name}'?" - # if self.main_frame.ask_yes_no("Switch Branch?", switch_q): - # self.ui_switch_branch(new_branch_name) # Needs method adjustment - + # Optionally ask to switch except (GitCommandError, ValueError) as e: self.main_frame.show_error("Error", f"Could not create branch:\n{e}") except Exception as e: @@ -941,7 +985,7 @@ class GitSvnSyncApp: if not selected_branch: self.main_frame.show_error("Error", "Select a branch."); return - # Avoid switching to the same branch + # Prevent switching to the same branch current_branch = self.main_frame.current_branch_var.get() if selected_branch == current_branch: self.main_frame.show_info("Info", f"Already on branch '{selected_branch}'.") @@ -952,14 +996,15 @@ class GitSvnSyncApp: # Delegate execution (ActionHandler checks for changes) try: + # Assuming ActionHandler has execute_switch_branch success = self.action_handler.execute_switch_branch( svn_path, selected_branch ) if success: self.main_frame.show_info("Success", f"Switched to branch '{selected_branch}'.") # Update UI after successful switch - self.update_current_branch_display() - self.refresh_branch_list() # Update highlight + self.update_current_branch_display() # Update label immediately + self.refresh_branch_list() # Update highlight in list # else: Handler raises error except ValueError as e: # Catch specific errors like uncommitted changes self.main_frame.show_error("Switch Blocked", str(e)) @@ -997,13 +1042,15 @@ class GitSvnSyncApp: # Delegate execution try: + # Attempt safe delete first success = self.action_handler.execute_delete_branch( - svn_path, selected_branch, force=False # Attempt safe delete first + svn_path, selected_branch, force=False ) if success: self.main_frame.show_info("Success", f"Branch '{selected_branch}' deleted.") self.refresh_branch_list() # Update list - # else: Handler raises error + # else: Handler should raise error + except GitCommandError as e: # Handle specific errors, like 'not fully merged' if "not fully merged" in str(e).lower(): @@ -1036,8 +1083,8 @@ class GitSvnSyncApp: def _clear_and_disable_fields(self): """Clears relevant GUI fields and disables most buttons.""" if hasattr(self, 'main_frame'): - mf = self.main_frame - # Clear Repo frame + mf = self.main_frame # Alias + # Clear Repo frame fields mf.svn_path_entry.delete(0, tk.END) mf.usb_path_entry.delete(0, tk.END) mf.bundle_name_entry.delete(0, tk.END) @@ -1045,12 +1092,13 @@ class GitSvnSyncApp: # Clear Commit/Tag/Branch frame fields mf.commit_message_var.set("") mf.autocommit_var.set(False) - mf.update_tag_list([]) - mf.update_branch_list([]) + mf.update_tag_list([]) # Clear tag listbox + mf.update_branch_list([]) # Clear branch listbox mf.set_current_branch_display("") - # Reset indicator and dependent buttons - self.update_svn_status_indicator("") # Disables state-dependent - # Disable general action buttons + # Reset indicator and dependent buttons/widgets + # This handles disabling Prepare, EditGitignore, Commit/Tag/Branch widgets + self.update_svn_status_indicator("") + # Disable general action buttons explicitly self._disable_general_buttons() self.logger.debug("GUI fields cleared/reset. Buttons disabled.") @@ -1058,7 +1106,7 @@ class GitSvnSyncApp: def _disable_general_buttons(self): """Disables buttons generally requiring only a loaded profile.""" if hasattr(self, 'main_frame'): - # List of general button attribute names + # List of general action button attribute names in main_frame button_names = [ 'create_bundle_button', 'fetch_bundle_button', 'manual_backup_button', 'save_settings_button' @@ -1073,11 +1121,11 @@ class GitSvnSyncApp: def _enable_function_buttons(self): """ Enables general action buttons. State-dependent buttons rely on - update_svn_status_indicator. + update_svn_status_indicator for their state. """ if hasattr(self, 'main_frame'): general_state = tk.NORMAL - # List of general button attribute names + # List of general action button attribute names button_names = [ 'create_bundle_button', 'fetch_bundle_button', 'manual_backup_button', 'save_settings_button' @@ -1089,7 +1137,7 @@ class GitSvnSyncApp: button.config(state=general_state) # Ensure state-dependent buttons reflect the current status - # This call handles Prepare, EditGitignore, Commit/Tag/Branch widgets + # This call updates Prepare, EditGitignore, Commit/Tag/Branch widget states current_svn_path = "" if hasattr(self.main_frame, 'svn_path_entry'): current_svn_path = self.main_frame.svn_path_entry.get() @@ -1111,15 +1159,53 @@ class GitSvnSyncApp: except Exception as e: # Log error showing the message box itself print(f"FATAL ERROR (and GUI error: {e}): {message}") + + +def resource_path(relative_path): + """ Ottiene il percorso assoluto della risorsa, funziona per dev e per PyInstaller """ + try: + # PyInstaller crea una cartella temporanea e salva il percorso in _MEIPASS + base_path = sys._MEIPASS + except Exception: + # _MEIPASS non esiste, siamo in modalità sviluppo normale + base_path = os.path.abspath(".") # Usa la directory corrente + return os.path.join(base_path, relative_path) # --- Application Entry Point --- def main(): """Main function: Creates Tkinter root and runs the application.""" root = tk.Tk() # Adjust min size for the new layout with tabs - # May need adjustment based on final widget sizes - root.minsize(750, 650) # Adjusted height back down slightly + root.minsize(750, 650) # Adjusted min height after tabbing + + # --- Imposta l'icona della finestra --- + try: + # Assumendo che 'app_icon.ico' sia nella stessa dir dello script + # o aggiunto correttamente a PyInstaller + icon_path = resource_path("GitUtility.ico") + # Usa wm_iconbitmap per Windows + if os.path.exists(icon_path): + # wm_iconbitmap si aspetta un file .ico su Windows + # Per Linux/Mac, si userebbe iconphoto con un PhotoImage (PNG) + if os.name == 'nt': # Solo per Windows + root.wm_iconbitmap(icon_path) + else: + # Su Linux/Mac potresti usare iconphoto con un file PNG + # icon_img = tk.PhotoImage(file=resource_path("app_icon.png")) + # root.iconphoto(True, icon_img) # 'True' per default icon + # Nota: Dovresti aggiungere app_icon.png con --add-data + pass # Per ora non facciamo nulla su altri OS + else: + # Log se l'icona non viene trovata nel percorso atteso + logging.warning(f"Window icon file not found at: {icon_path}") + except tk.TclError as e: + # Logga se c'è un errore nel caricare/impostare l'icona + logging.warning(f"Could not set window icon: {e}") + except Exception as e: + # Logga altri errori imprevisti + logging.warning(f"Unexpected error setting window icon: {e}", exc_info=True) + app = None # Initialize app variable try: app = GitSvnSyncApp(root) @@ -1150,7 +1236,7 @@ def main(): if __name__ == "__main__": # Setup basic logging immediately at startup - # This ensures logs are captured even if setup_logger fails later log_format = "%(asctime)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s" - logging.basicConfig(level=logging.INFO, format=log_format) # Consider level=logging.DEBUG for more detail + # Consider level=logging.DEBUG for more detail during development + logging.basicConfig(level=logging.INFO, format=log_format) main() \ No newline at end of file diff --git a/GitUtility.spec b/GitUtility.spec new file mode 100644 index 0000000..3ac9cf1 --- /dev/null +++ b/GitUtility.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['GitUtility.py'], + pathex=[], + binaries=[], + datas=[('git_svn_sync.ini', '.')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='GitUtility', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='GitUtility', +) diff --git a/action_handler.py b/action_handler.py index 296991f..2333770 100644 --- a/action_handler.py +++ b/action_handler.py @@ -22,6 +22,7 @@ class ActionHandler: self.git_commands = git_commands self.backup_handler = backup_handler + def _perform_backup_if_enabled(self, svn_path, profile_name, autobackup_enabled, backup_base_dir, excluded_extensions, excluded_dirs): @@ -37,15 +38,16 @@ class ActionHandler: excluded_dirs (set): Directory names to exclude. Raises: - IOError: If the backup process fails. + IOError: If the backup process fails for any reason. """ if not autobackup_enabled: self.logger.debug("Autobackup disabled, skipping backup.") - return # Backup not needed, proceed + return # Backup not needed, proceed successfully self.logger.info("Autobackup enabled. Starting backup...") try: # Delegate backup creation to the BackupHandler instance + # It will raise exceptions on failure backup_path = self.backup_handler.create_zip_backup( svn_path, backup_base_dir, @@ -55,11 +57,12 @@ class ActionHandler: ) # Log success if backup handler doesn't raise an error self.logger.info(f"Backup completed successfully: {backup_path}") - # No explicit return value needed on success, failure indicated by exception + # No explicit return value needed on success except Exception as backup_e: # Log error and re-raise as IOError to signal critical failure self.logger.error(f"Backup failed: {backup_e}", exc_info=True) + # Standardize on IOError for backup failures passed up raise IOError(f"Autobackup failed: {backup_e}") from backup_e @@ -85,17 +88,14 @@ class ActionHandler: raise ValueError("Repository is already prepared.") # Attempt preparation using GitCommands + # Any exception (GitCommandError, ValueError, IOError) will be caught try: self.git_commands.prepare_svn_for_git(svn_path) self.logger.info("Repository prepared successfully.") return True - except (GitCommandError, ValueError, IOError) as e: - # Log and re-raise known errors for the UI layer to handle - self.logger.error(f"Failed to prepare repository: {e}", exc_info=True) - raise except Exception as e: - # Log and re-raise unexpected errors - self.logger.exception(f"Unexpected error during preparation: {e}") + # Log and re-raise any exception from prepare_svn_for_git + self.logger.error(f"Failed to prepare repository: {e}", exc_info=True) raise @@ -142,7 +142,7 @@ class ActionHandler: commit_msg_to_use = commit_message if commit_message else \ f"Autocommit '{profile_name}' before bundle" self.logger.debug(f"Using autocommit message: '{commit_msg_to_use}'") - # Perform commit (raises error on failure) + # Perform commit (raises error on failure, returns bool) self.git_commands.git_commit(svn_path, commit_msg_to_use) # Log based on return value? git_commit already logs detail. self.logger.info("Autocommit attempt finished.") @@ -172,7 +172,7 @@ class ActionHandler: try: os.remove(bundle_full_path) except OSError: - self.logger.warning(f"Could not remove empty bundle file.") + self.logger.warning("Could not remove empty bundle file.") return None # Indicate non-fatal issue (empty bundle) except Exception as bundle_e: # Log and re-raise bundle creation errors @@ -205,7 +205,7 @@ class ActionHandler: # Delegate to GitCommands; it raises GitCommandError on conflict/failure self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) self.logger.info("Fetch/merge process completed successfully.") - # No return value needed, success indicated by no exception + # No return needed, success indicated by no exception except Exception as fetch_e: # Log and re-raise any error from fetch/merge self.logger.error(f"Fetch/merge failed: {fetch_e}", exc_info=True) @@ -221,13 +221,13 @@ class ActionHandler: commit_message (str): The commit message (must not be empty). Returns: - bool: True if commit made, False if no changes. + bool: True if a commit was made, False if no changes were committed. Raises: ValueError: If commit_message is empty. GitCommandError/Exception: If commit command fails. """ if not commit_message: - # Should be validated by caller (UI layer) + # This validation should ideally happen before calling this method self.logger.error("Manual commit attempt with empty message.") raise ValueError("Commit message cannot be empty.") @@ -274,9 +274,10 @@ class ActionHandler: # Perform the pre-tag commit with the provided message self.logger.debug(f"Performing pre-tag commit: '{commit_message}'") - # git_commit raises error on failure, returns bool on success/no changes - self.git_commands.git_commit(svn_path, commit_message) - self.logger.info("Pre-tag commit attempt finished.") + # git_commit raises error on failure, returns bool otherwise + commit_made = self.git_commands.git_commit(svn_path, commit_message) + # Log based on return value? git_commit logs details. + self.logger.info(f"Pre-tag commit attempt finished (made={commit_made}).") else: self.logger.info("No uncommitted changes detected before tagging.") @@ -303,11 +304,11 @@ class ActionHandler: def execute_checkout_tag(self, svn_path, tag_name): """ - Executes checkout for the specified tag after checking for changes. + Executes checkout for the specified tag after checking changes. Args: svn_path (str): Validated path to repository. - tag_name (str): The tag name to check out (already validated by caller). + tag_name (str): The tag name to check out (validated by caller). Returns: bool: True on successful checkout. @@ -316,7 +317,7 @@ class ActionHandler: GitCommandError/Exception: If status check or checkout fails. """ if not tag_name: - raise ValueError("Tag name required for checkout.") # Should be caught earlier + raise ValueError("Tag name required for checkout.") self.logger.info(f"Executing checkout tag '{tag_name}' in: {svn_path}") @@ -336,11 +337,15 @@ class ActionHandler: # --- Execute Checkout --- try: - # git_commands.checkout_tag raises GitCommandError on failure + # git_commands.checkout_tag raises error on failure checkout_success = self.git_commands.checkout_tag(svn_path, tag_name) - # If no exception, assume success - self.logger.info(f"Tag '{tag_name}' checked out.") - return True + if checkout_success: + self.logger.info(f"Tag '{tag_name}' checked out.") + return True + else: + # This path should theoretically not be reached + self.logger.error("Checkout command reported failure unexpectedly.") + raise GitCommandError("Checkout failed for unknown reason.") except Exception as e: # Catch errors during checkout (e.g., tag not found) self.logger.error(f"Failed to checkout tag '{tag_name}': {e}", @@ -365,6 +370,7 @@ class ActionHandler: self.git_commands.create_branch(svn_path, branch_name, start_point) return True except Exception as e: + # Log and re-raise error self.logger.error(f"Failed to create branch: {e}", exc_info=True) raise @@ -388,9 +394,10 @@ class ActionHandler: if has_changes: msg = "Uncommitted changes exist. Commit or stash first." self.logger.error(f"Switch blocked: {msg}") - raise ValueError(msg) # Raise specific error + raise ValueError(msg) # Raise specific error for UI self.logger.debug("No uncommitted changes found.") except Exception as e: + # Catch errors during status check self.logger.error(f"Status check error before switch: {e}", exc_info=True) raise # Re-raise status check errors @@ -401,6 +408,7 @@ class ActionHandler: success = self.git_commands.checkout_branch(svn_path, branch_name) return success except Exception as e: + # Catch errors during switch (e.g., branch not found) self.logger.error(f"Failed switch to branch '{branch_name}': {e}", exc_info=True) raise @@ -417,27 +425,27 @@ class ActionHandler: """ if not branch_name: raise ValueError("Branch name required for delete.") - # Add checks for main/master? Done in UI layer. + # Add checks for main/master? UI layer handles this. + self.logger.info(f"Executing delete branch '{branch_name}' (force={force}).") try: # Delegate to git_commands, raises error on failure success = self.git_commands.delete_branch(svn_path, branch_name, force) return success except Exception as e: - # Catch errors (like not fully merged if force=False) + # Catch errors (like not merged, needs force) self.logger.error(f"Failed delete branch '{branch_name}': {e}", exc_info=True) raise - # --- ADDED: Delete Tag Action --- def execute_delete_tag(self, svn_path, tag_name): """ Executes deletion for the specified tag. Args: svn_path (str): Validated path to repository. - tag_name (str): The tag name to delete (validated by caller). + tag_name (str): The tag name to delete (already validated). Returns: bool: True on successful deletion. @@ -445,7 +453,7 @@ class ActionHandler: GitCommandError/ValueError/Exception: If delete fails. """ if not tag_name: - # Should be validated by caller (UI layer) + # Should be validated by caller raise ValueError("Tag name required for deletion.") self.logger.info(f"Executing delete tag '{tag_name}' in: {svn_path}") diff --git a/create_exe.bat b/create_exe.bat new file mode 100644 index 0000000..e69de29 diff --git a/gui.py b/gui.py index d1340dd..7871bc2 100644 --- a/gui.py +++ b/gui.py @@ -26,9 +26,11 @@ class Tooltip: def showtip(self): """Display text in a tooltip window.""" - self.hidetip() # Hide any existing tooltip first + # Hide any existing tooltip first + self.hidetip() + # Avoid error if widget is destroyed before showing tooltip if not self.widget.winfo_exists(): - return # Avoid error if widget destroyed + return try: # Get widget position relative to widget itself x_rel, y_rel, _, _ = self.widget.bbox("insert") @@ -39,7 +41,7 @@ class Tooltip: x_pos = x_root + x_rel + 25 y_pos = y_root + y_rel + 25 except tk.TclError: - # Fallback position calculation if bbox fails + # 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() @@ -50,7 +52,7 @@ class Tooltip: # Create the tooltip window as a Toplevel self.tooltip_window = tk.Toplevel(self.widget) tw = self.tooltip_window - # Remove window decorations (border, title bar) + # 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)}") @@ -69,7 +71,9 @@ class Tooltip: 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 @@ -92,14 +96,17 @@ class GitignoreEditorWindow(tk.Toplevel): # 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 window on top of parent - self.protocol("WM_DELETE_WINDOW", self._on_close) # Handle close button + # --- 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) @@ -129,7 +136,7 @@ class GitignoreEditorWindow(tk.Toplevel): text="Save and Close", command=self._save_and_close ) - self.save_button.grid(row=0, column=2, padx=5) # Right-center + self.save_button.grid(row=0, column=2, padx=5) # Place in right-center # Cancel button self.cancel_button = ttk.Button( @@ -137,19 +144,19 @@ class GitignoreEditorWindow(tk.Toplevel): text="Cancel", command=self._on_close ) - self.cancel_button.grid(row=0, column=1, padx=5) # Left-center + self.cancel_button.grid(row=0, column=1, padx=5) # Place in left-center - # Load initial file content + # --- Load Initial Content --- self._load_file() - # Center window relative to parent + # 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() # Ensure window size is calculated correctly - # Get parent window geometry + 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() @@ -160,65 +167,68 @@ class GitignoreEditorWindow(tk.Toplevel): # Calculate position for centering x_pos = parent_x + (parent_width // 2) - (win_width // 2) y_pos = parent_y + (parent_height // 2) - (win_height // 2) - # Prevent window going off-screen (basic check) + # 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 + # 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 empty content + 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.") + 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 + # Store original content and update editor text self.original_content = content - self.text_editor.delete("1.0", tk.END) # Clear previous 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 loading new content + # Reset undo stack after programmatically changing text self.text_editor.edit_reset() except IOError as e: - # Handle file reading errors - self.logger.error(f"Read error: {e}", exc_info=True) + # 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 + parent=self # Show error relative to this dialog ) except Exception as e: - # Handle other unexpected errors during loading - self.logger.exception(f"Unexpected load error: {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 file:\n{e}", + 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, normalize whitespace and newline + # 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 comparison + + # Normalize original content similarly for accurate comparison normalized_original = self.original_content.rstrip() if normalized_original: normalized_original += "\n" - # Check if content actually changed + # 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) @@ -230,20 +240,22 @@ class GitignoreEditorWindow(tk.Toplevel): 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 state and reset undo stack + # 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"Write error: {e}", exc_info=True) + 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 save error: {e}") + self.logger.exception(f"Unexpected error saving file: {e}") messagebox.showerror("Unexpected Error", f"An unexpected error occurred saving file:\n{e}", parent=self) @@ -276,11 +288,12 @@ class GitignoreEditorWindow(tk.Toplevel): parent=self ) if response is True: # User chose Yes (Save) - self._save_and_close() # Attempts save, closes only if successful + # 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 (response is None - Cancel): Do nothing, keep window open else: # No changes detected, simply close the window self.destroy() @@ -341,7 +354,6 @@ class CreateTagDialog(simpledialog.Dialog): 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~^:?*[\\]+(?") # For branch display + self.current_branch_var = tk.StringVar(value="") # --- Create Main Layout Sections --- - # Profile selection is always visible at the top self._create_profile_frame() - - # --- Create Notebook for Tabs --- - # Add padding below tabs for separation from action buttons - 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 --- - # Add padding within each tab frame for content spacing - 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 to the notebook with descriptive text - self.notebook.add(self.setup_tab_frame, text=' Setup & Backup ') # Combined setup - self.notebook.add(self.commit_branch_tab_frame, text=' Commit & Branches ') - self.notebook.add(self.tags_gitignore_tab_frame, text=' Tags & Gitignore ') - - # --- Populate Tabs with Widgets --- - self._populate_setup_tab() - self._populate_commit_branch_tab() - self._populate_tags_gitignore_tab() - - # --- Core Actions Frame (Below Tabs) --- + self._create_notebook_with_tabs() self._create_function_frame() - - # --- Log Area (Bottom) --- self._create_log_area() # --- Initial State Configuration --- self._initialize_profile_selection() - # Set initial state of backup widgets based on checkbox value self.toggle_backup_dir() @@ -519,82 +503,78 @@ class MainFrame(ttk.Frame): self.profile_frame = ttk.LabelFrame( self, text="Profile Configuration", padding=(10, 5) ) - # Pack frame at the top, below potential menu bar, expand horizontally self.profile_frame.pack(pady=(0, 5), fill="x") - # Allow dropdown column (column 1) 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", # Prevent typing custom values - width=35, - values=self.initial_profile_sections # Set initial list + 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 profile settings self.profile_dropdown.bind( "<>", - lambda event: self.load_profile_settings_callback( - self.profile_var.get() - ) + lambda e: self.load_profile_settings_callback(self.profile_var.get()) ) - # Trace variable for programmatic changes to also trigger load self.profile_var.trace_add( "write", - lambda *args: self.load_profile_settings_callback( - self.profile_var.get() - ) + 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 # Use controller's save method + self.profile_frame, text="Save Settings", + command=self.save_profile_callback ) - # Place button next to the dropdown 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.") + "Save settings for selected profile.") - # Add Profile Button self.add_profile_button = ttk.Button( - self.profile_frame, - text="Add", - width=5, # Fixed small width + 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, # Slightly wider than Add + 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 _populate_setup_tab(self): - """Creates and places widgets for the Setup & Backup tab.""" - parent_frame = self.setup_tab_frame + 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 sub-frames within the tab for better organization - # Pack them vertically, expanding horizontally - repo_paths_frame = self._create_repo_paths_frame(parent_frame) + # 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_frame) + backup_config_frame = self._create_backup_config_frame(parent_tab_frame) backup_config_frame.pack(pady=5, fill="x", expand=False) @@ -602,12 +582,11 @@ class MainFrame(ttk.Frame): """Creates the sub-frame for repository paths and bundle names.""" frame = ttk.LabelFrame(parent, text="Repository & Bundle Paths", padding=(10, 5)) - # Define columns for layout consistency col_label = 0 col_entry = 1 col_button = 2 col_indicator = 3 - # Configure entry column (1) to expand horizontally + # Configure entry column to expand horizontally frame.columnconfigure(col_entry, weight=1) # Row 0: SVN Path @@ -616,126 +595,118 @@ class MainFrame(ttk.Frame): 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()) - ) + 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: self.browse_folder_callback(self.svn_path_entry) + 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 (Green/Red dot) + 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)") + 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: self.browse_folder_callback(self.usb_path_entry) + 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) + 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) - # 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) + 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) - # Span entry across entry and button columns - self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, - sticky=tk.EW, padx=5, pady=3) + self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) - return frame # Return the created frame + 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)) - # Define columns + frame = ttk.LabelFrame(parent, text="Backup Configuration (ZIP)", padding=(10, 5)) col_label = 0 col_entry = 1 col_button = 2 - # Configure entry column to expand - frame.columnconfigure(col_entry, weight=1) + 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 + frame, + text="Automatic Backup before Create/Fetch", + variable=self.autobackup_var, + command=self.toggle_backup_dir ) - # Span checkbox across all columns - self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, - sticky=tk.W, padx=5, pady=(5, 0)) + 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 + # 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) - self.backup_dir_button = ttk.Button( - frame, text="Browse...", width=9, command=self.browse_backup_dir, + frame, + textvariable=self.backup_dir_var, + width=60, state=tk.DISABLED ) - self.backup_dir_button.grid(row=1, column=col_button, sticky=tk.W, - padx=(0, 5), pady=5) + 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 Entry + # 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 + 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)") + 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 # Return the created frame + return frame - def _populate_commit_branch_tab(self): + def _populate_commit_branch_tab(self, parent_tab_frame): """Creates and places widgets for the Commit & Branches tab.""" - parent_frame = self.commit_branch_tab_frame - # Configure grid columns for overall tab layout - parent_frame.columnconfigure(0, weight=1) # Column with listbox expands - parent_frame.rowconfigure(1, weight=1) # Row with listbox expands vertically + # 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 - # --- Commit Section (Top) --- - commit_subframe = self._create_commit_management_frame(parent_frame) - commit_subframe.grid(row=0, column=0, columnspan=2, # Span both columns - sticky="ew", padx=0, pady=(0, 10)) + # 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)) - # --- Branch Section (Bottom) --- - branch_subframe = self._create_branch_management_frame(parent_frame) - branch_subframe.grid(row=1, column=0, columnspan=2, # Span both columns - sticky="nsew", padx=0, pady=0) + # 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): @@ -744,87 +715,98 @@ class MainFrame(ttk.Frame): # Configure internal columns frame.columnconfigure(1, weight=1) # Entry expands - # Row 0: Autocommit Checkbox (for Create Bundle action) + # 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 + 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 using the message before Create Bundle.") + 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, state=tk.DISABLED + 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.") + 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, - command=self.manual_commit_callback, state=tk.DISABLED + 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.") + self.create_tooltip(self.commit_button, "Manually commit staged changes with this message.") - return frame # Return the created frame + 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(0, weight=1) # Listbox column expands + 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, - font=("Segoe UI", 9, "bold"), relief=tk.SUNKEN, padding=(3, 1) + 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? - self.current_branch_display.grid(row=0, column=1, columnspan=2, # Span 2 - sticky="ew", padx=5, pady=3) - self.create_tooltip(self.current_branch_display, - "The currently active branch or state.") + # 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:") - branch_list_label.grid(row=1, column=0, columnspan=3, # Span all columns - sticky="w", padx=5, pady=(10, 0)) + # 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.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, exportselection=False, - selectmode=tk.SINGLE, font=("Consolas", 9) + 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, + 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).") + 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) - branch_button_frame.grid(row=2, column=3, sticky="ns", # North-South align - padx=(10, 5), pady=(0, 5)) # Add left padding + # 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 @@ -847,7 +829,7 @@ class MainFrame(ttk.Frame): 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 selected branch.") + 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, @@ -859,110 +841,141 @@ class MainFrame(ttk.Frame): return frame # Return the created frame - def _populate_tags_gitignore_tab(self): + def _populate_tags_gitignore_tab(self, parent_tab_frame): """Creates and places widgets for the Tags & Gitignore tab.""" - parent_frame = self.tags_gitignore_tab_frame - # Configure grid - parent_frame.columnconfigure(0, weight=1) # Listbox expands - parent_frame.rowconfigure(0, weight=1) # Listbox expands vertically + # 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 Listing Area --- - tag_list_frame = ttk.LabelFrame(parent_frame, text="Tags", padding=5) - 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) + # --- 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( - tag_list_frame, height=8, exportselection=False, - selectmode=tk.SINGLE, font=("Consolas", 9) + 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( - tag_list_frame, orient=tk.VERTICAL, command=self.tag_listbox.yview + 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 - # --- Tag/Gitignore Action Buttons Area --- (Vertical column) - tag_button_frame = ttk.Frame(parent_frame) - tag_button_frame.grid(row=0, column=1, rowspan=2, # Span rows potentially - sticky="ns", padx=(0, 5), pady=5) - button_width_tag = 18 # Consistent width + 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( - tag_button_frame, text="Refresh Tags", width=button_width_tag, + 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( - tag_button_frame, text="Create Tag...", width=button_width_tag, + 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( - tag_button_frame, text="Checkout Selected Tag", width=button_width_tag, + 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).") - # --- ADDED: Delete Tag Button --- + # Delete Tag Button self.delete_tag_button = ttk.Button( - tag_button_frame, text="Delete Selected Tag", width=button_width_tag, - command=self.delete_tag_callback, state=tk.DISABLED + 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 the selected tag locally.") + self.create_tooltip(self.delete_tag_button, "Delete selected tag locally.") - # Edit .gitignore button (also in this column) + # Edit .gitignore Button self.edit_gitignore_button = ttk.Button( - tag_button_frame, text="Edit .gitignore", width=button_width_tag, + 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 behavior centers horizontally + 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 @@ -991,11 +1004,40 @@ class MainFrame(ttk.Frame): 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 + # 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 --- @@ -1009,13 +1051,10 @@ class MainFrame(ttk.Frame): 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) + """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): @@ -1024,9 +1063,11 @@ class MainFrame(ttk.Frame): 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) @@ -1035,61 +1076,77 @@ class MainFrame(ttk.Frame): """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") + self.profile_var.set("default") # Triggers load else: - self.profile_var.set(sections[0]) + self.profile_var.set(sections[0]) # Triggers load else: - self.profile_var.set("") + 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'): - logging.error("Tag listbox missing for update.") + # 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) + self.tag_listbox.delete(0, tk.END) # Clear list if tags_with_subjects: - # Reset color if needed + # Reset text color if it was previously greyed out try: - if self.tag_listbox.cget("fg") == "grey": + 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: - pass # Ignore color errors - # Insert items + # 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: - display = f"{name}\t({subject})" # Tab separation + # Use tab separation for basic alignment + display = f"{name}\t({subject})" self.tag_listbox.insert(tk.END, display) else: - # Show placeholder + # Show placeholder text if no tags self.tag_listbox.insert(tk.END, "(No tags found)") try: - self.tag_listbox.config(fg="grey") + self.tag_listbox.config(fg="grey") # Dim placeholder text except tk.TclError: - pass # Ignore color errors + pass # Ignore color setting errors + except tk.TclError as e: - logging.error(f"TclError updating tags: {e}") + logging.error(f"TclError updating tag listbox: {e}") except Exception as e: - logging.error(f"Error updating tags: {e}", exc_info=True) + 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: - item = self.tag_listbox.get(indices[0]) + 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)": - # Get text before the first tab + # Extract name (text before the first tab) tag_name = item.split('\t', 1)[0] - return tag_name.strip() - return None # No selection or invalid item + tag_name = tag_name.strip() # Remove any extra spaces + return tag_name # Return name or None def update_branch_list(self, branches): @@ -1099,7 +1156,7 @@ class MainFrame(ttk.Frame): return try: current = self.current_branch_var.get() # Get displayed current branch - self.branch_listbox.delete(0, tk.END) + self.branch_listbox.delete(0, tk.END) # Clear list if branches: # Reset color if needed try: @@ -1110,9 +1167,9 @@ class MainFrame(ttk.Frame): for branch in branches: is_current = (branch == current) # Add '*' prefix for current branch display - display = f"* {branch}" if is_current else f" {branch}" - self.branch_listbox.insert(tk.END, display) - # Highlight current branch in the list + 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'} @@ -1120,7 +1177,8 @@ class MainFrame(ttk.Frame): else: # Show placeholder if no branches self.branch_listbox.insert(tk.END, "(No local branches?)") - try: self.branch_listbox.config(fg="grey") + 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}") @@ -1130,13 +1188,14 @@ class MainFrame(ttk.Frame): 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 - return item.lstrip("* ").strip() - return None # No selection + branch_name = item.lstrip("* ").strip() + return branch_name # Return name or None def set_current_branch_display(self, branch_name): @@ -1174,13 +1233,12 @@ class MainFrame(ttk.Frame): def create_tooltip(self, widget, text): """Creates a tooltip for a given widget.""" tooltip = Tooltip(widget, text) - # Use add='+' to avoid overwriting other bindings + # 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