diff --git a/GitTool.py b/GitTool.py index 5495523..aa5fcf7 100644 --- a/GitTool.py +++ b/GitTool.py @@ -11,7 +11,8 @@ import zipfile # Import zipfile module from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR from git_commands import GitCommands, GitCommandError from logger_config import setup_logger -from gui import MainFrame +# Import the new editor window class from gui +from gui import MainFrame, GitignoreEditorWindow class GitSvnSyncApp: """ @@ -49,7 +50,7 @@ class GitSvnSyncApp: master, load_profile_settings_cb=self.load_profile_settings, browse_folder_cb=self.browse_folder, - update_svn_status_cb=self.update_svn_status_indicator, + update_svn_status_cb=self.update_svn_status_indicator, # Connects to method below 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, @@ -57,7 +58,8 @@ class GitSvnSyncApp: 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 # Connect button to method + manual_backup_cb=self.manual_backup, + open_gitignore_editor_cb=self.open_gitignore_editor # Pass the new callback ) except Exception as e: self.logger.critical(f"Failed to initialize MainFrame GUI: {e}", exc_info=True) @@ -80,7 +82,7 @@ class GitSvnSyncApp: # The trace on profile_var in MainFrame should trigger load_profile_settings automatically else: self.logger.warning("No profile selected on startup. Disabling action buttons.") - self._clear_and_disable_fields() + self._clear_and_disable_fields() # Clear fields and disable buttons self.logger.info("Application started successfully.") @@ -89,8 +91,11 @@ class GitSvnSyncApp: """Handles the event when the user tries to close the window.""" self.logger.info("Close button clicked. Preparing to exit.") # Add checks for unsaved changes or running ops if necessary + # Example: Check if gitignore editor is open and has unsaved changes? + # (This might require tracking open editor instances) + self.logger.info("Application closing.") - self.master.destroy() + self.master.destroy() # Close the Tkinter window # --- Profile Management Callbacks/Methods --- @@ -139,11 +144,11 @@ class GitSvnSyncApp: self.main_frame.backup_exclude_extensions_var.set(exclude_extensions) # Update exclude extensions entry self.main_frame.toggle_backup_dir() # Update backup dir entry state - # Update SVN status indicator based on the loaded path + # Update SVN status indicator and related buttons based on the loaded path self.update_svn_status_indicator(svn_path) # Enable function buttons now that a valid profile is loaded - self._enable_function_buttons() + self._enable_function_buttons() # This also calls update_svn_status_indicator again, might be redundant but safe self.logger.info(f"Settings loaded successfully for profile '{profile_name}'.") else: self.logger.error("Main frame not available, cannot load settings into GUI.") @@ -251,11 +256,14 @@ class GitSvnSyncApp: try: success = self.config_manager.remove_profile_section(profile_to_remove) if success: - self.config_manager.save_config() + self.config_manager.save_config() # Save changes after successful removal self.logger.info(f"Profile '{profile_to_remove}' removed successfully.") + # Update dropdown - GUI method handles selection logic updated_sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(updated_sections) + # Loading settings for the new selection will be triggered by trace else: + # ConfigManager already logged the specific reason self.main_frame.show_error("Error", f"Failed to remove profile '{profile_to_remove}'. Check logs.") except Exception as e: @@ -270,20 +278,22 @@ class GitSvnSyncApp: def browse_folder(self, entry_widget): """Opens a folder selection dialog and updates the specified Tkinter Entry widget.""" self.logger.debug(f"Browse folder requested for widget: {entry_widget}") + # Suggest initial directory based on current entry content, fallback to home dir initial_dir = entry_widget.get() or os.path.expanduser("~") - if not os.path.isdir(initial_dir): + if not os.path.isdir(initial_dir): # Handle case where entry has invalid path initial_dir = os.path.expanduser("~") directory = filedialog.askdirectory( initialdir=initial_dir, title="Select Directory", - parent=self.master + parent=self.master # Make dialog modal to main window ) - if directory: + if directory: # If a directory was selected (not cancelled) self.logger.debug(f"Directory selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) + # If the SVN path entry was the one updated, trigger status check if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: @@ -291,17 +301,59 @@ class GitSvnSyncApp: def update_svn_status_indicator(self, svn_path): - """Checks if the given SVN path is prepared for Git and updates the GUI indicator/button.""" + """ + Checks if the given SVN path is prepared for Git (contains .git) and updates the GUI indicator. + Also enables/disables the 'Prepare' and 'Edit .gitignore' buttons based on path validity. + """ + is_valid_dir = False is_prepared = False - if svn_path and os.path.isdir(svn_path): + gitignore_button_state = tk.DISABLED # Default state for gitignore button + + if svn_path and os.path.isdir(svn_path): # Check if path is a valid directory + is_valid_dir = True + # Enable gitignore button ONLY if path is valid + gitignore_button_state = tk.NORMAL + # Check if prepared (.git exists) git_dir_path = os.path.join(svn_path, ".git") is_prepared = os.path.exists(git_dir_path) - self.logger.debug(f"Checking SVN status for path '{svn_path}'. '.git' found: {is_prepared}") + self.logger.debug(f"Checking SVN status for path '{svn_path}'. Valid dir: {is_valid_dir}, Prepared: {is_prepared}") else: self.logger.debug(f"SVN path '{svn_path}' is invalid or empty. Status set to unprepared.") + gitignore_button_state = tk.DISABLED # Keep disabled if path invalid + # Update the visual indicator and Prepare button state via the MainFrame method if hasattr(self, 'main_frame'): - self.main_frame.update_svn_indicator(is_prepared) + self.main_frame.update_svn_indicator(is_prepared) # Handles indicator + Prepare button + # Update the Edit .gitignore button state separately + if hasattr(self.main_frame, 'edit_gitignore_button'): + self.main_frame.edit_gitignore_button.config(state=gitignore_button_state) + + + def open_gitignore_editor(self): + """ + Opens the editor window for the .gitignore file in the current SVN path. + """ + self.logger.info("--- Action: Edit .gitignore ---") + + # 1. Validate the SVN Path + svn_path = self._get_and_validate_svn_path("Edit .gitignore") + if not svn_path: + # Error message already shown by validation method + return # Stop if path is invalid + + # 2. Determine the full path to the .gitignore file + gitignore_path = os.path.join(svn_path, ".gitignore") + self.logger.debug(f"Target .gitignore path: {gitignore_path}") + + # 3. Open the Editor Window + try: + # Create and run the editor window (it's modal via grab_set) + editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) + # The window will run its own loop until closed due to grab_set/transient + self.logger.debug("Gitignore editor window opened and is now blocking.") + except Exception as e: + 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 --- @@ -344,16 +396,20 @@ class GitSvnSyncApp: svn_path = self._get_and_validate_svn_path("Prepare SVN") if not svn_path: return + # Save settings *before* potentially modifying .gitignore via prepare command if not self.save_profile_settings(): self.logger.warning("Prepare SVN: Could not save profile settings before proceeding.") + # Decide whether to stop or continue + # Check if already prepared (safe check) git_dir_path = os.path.join(svn_path, ".git") if os.path.exists(git_dir_path): self.logger.info(f"Repository at '{svn_path}' is already prepared.") - self.main_frame.show_info("Already Prepared", f"The repository at:\n{svn_path}\nis already prepared.") - self.update_svn_status_indicator(svn_path) + self.main_frame.show_info("Already Prepared", f"The repository at:\n{svn_path}\nis already prepared for Git.") + self.update_svn_status_indicator(svn_path) # Ensure GUI is consistent return + # Execute Preparation self.logger.info(f"Executing git preparation steps for: {svn_path}") try: self.git_commands.prepare_svn_for_git(svn_path) @@ -363,7 +419,7 @@ class GitSvnSyncApp: except (GitCommandError, ValueError) as e: self.logger.error(f"Error preparing SVN repository: {e}") self.main_frame.show_error("Preparation Error", f"Failed to prepare repository:\n{svn_path}\n\nError: {e}") - self.update_svn_status_indicator(svn_path) + self.update_svn_status_indicator(svn_path) # Update indicator (likely stays red) except Exception as e: self.logger.exception(f"Unexpected error during SVN preparation: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during preparation:\n{e}") @@ -379,6 +435,7 @@ class GitSvnSyncApp: self.main_frame.show_error("Error", "Cannot create bundle without a selected profile.") return + # Validate paths and names 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") @@ -389,6 +446,7 @@ class GitSvnSyncApp: self.logger.error("Create Bundle: Bundle name cannot be empty.") self.main_frame.show_error("Input Error", "Please enter a name for the bundle file.") return + # Ensure .bundle extension if not bundle_name.lower().endswith(".bundle"): self.logger.warning(f"Bundle name '{bundle_name}' does not end with '.bundle'. Adding extension.") bundle_name += ".bundle" @@ -398,15 +456,17 @@ class GitSvnSyncApp: bundle_full_path = os.path.join(usb_path, bundle_name) self.logger.debug(f"Target bundle file path: {bundle_full_path}") + # Save settings before proceeding if not self.save_profile_settings(): self.logger.warning("Create Bundle: Could not save profile settings before proceeding.") + # Decide whether to stop # --- Backup --- if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup enabled. Starting backup...") if not self.create_backup(svn_path, profile): # Pass profile name for exclusions self.logger.error("Bundle creation aborted due to backup failure.") - return + return # Stop the process if backup fails # --- Autocommit --- if self.main_frame.autocommit_var.get(): @@ -415,20 +475,21 @@ class GitSvnSyncApp: if self.git_commands.git_status_has_changes(svn_path): self.logger.info("Changes detected, performing autocommit...") custom_message = self.main_frame.commit_message_var.get().strip() - commit_message_to_use = custom_message if custom_message else f"Autocommit profile '{profile}'" # Use profile in default msg + # Use custom message or a default including profile name + commit_message_to_use = custom_message if custom_message else f"Autocommit profile '{profile}'" self.logger.debug(f"Using commit message: '{commit_message_to_use}'") commit_made = self.git_commands.git_commit(svn_path, message=commit_message_to_use) if commit_made: self.logger.info("Autocommit successful.") - else: - self.logger.warning("Status reported changes, but autocommit resulted in 'nothing to commit'.") + # else: 'nothing to commit' logged by git_commit method + else: self.logger.info("No changes detected, skipping autocommit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Error during autocommit process: {e}") self.main_frame.show_error("Autocommit Error", f"Failed to check status or commit changes:\n{e}") - return + return # Stop if autocommit fails except Exception as e: self.logger.exception(f"Unexpected error during autocommit: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during autocommit:\n{e}") @@ -437,22 +498,28 @@ class GitSvnSyncApp: # --- Create Bundle --- self.logger.info(f"Creating Git bundle at: {bundle_full_path}") try: + # Call the decoupled GitCommands method self.git_commands.create_git_bundle(svn_path, bundle_full_path) - if os.path.exists(bundle_full_path) and os.path.getsize(bundle_full_path) > 0: # Check if file exists and is not empty + + # Check if bundle file was actually created and has content + if os.path.exists(bundle_full_path) and os.path.getsize(bundle_full_path) > 0: self.logger.info("Git bundle created successfully.") self.main_frame.show_info("Success", f"Git bundle created successfully:\n{bundle_full_path}") else: + # Likely the 'empty bundle' case or other non-fatal issue self.logger.warning("Bundle file was not created or is empty (likely no new commits).") self.main_frame.show_warning("Bundle Not Created", "Bundle file was not created or is empty.\nThis usually means the repository had no new commits.") - # Attempt to remove empty file if it exists + # Clean up empty file if it exists if os.path.exists(bundle_full_path): try: os.remove(bundle_full_path) except OSError: pass # Ignore error removing empty file except (GitCommandError, ValueError) as e: + # Handle errors from git_commands self.logger.error(f"Error creating Git bundle: {e}") self.main_frame.show_error("Bundle Creation Error", f"Failed to create Git bundle:\n{e}") except Exception as e: + # Catch unexpected errors self.logger.exception(f"Unexpected error during bundle creation: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during bundle creation:\n{e}") @@ -466,6 +533,7 @@ class GitSvnSyncApp: self.main_frame.show_error("Error", "Cannot fetch from bundle without a selected profile.") 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") @@ -480,50 +548,72 @@ class GitSvnSyncApp: bundle_full_path = os.path.join(usb_path, bundle_updated_name) self.logger.debug(f"Source bundle file path: {bundle_full_path}") - if not os.path.isfile(bundle_full_path): + # Check if Bundle File Exists + if not os.path.isfile(bundle_full_path): # Check if it's specifically a file self.logger.error(f"Fetch Bundle: Bundle file does not exist or is not a file: '{bundle_full_path}'") self.main_frame.show_error("File Not Found", f"The specified bundle file does not exist:\n{bundle_full_path}") return + # Save settings before proceeding if not self.save_profile_settings(): self.logger.warning("Fetch Bundle: Could not save profile settings before proceeding.") + # Decide whether to stop # --- Backup --- if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup enabled. Starting backup...") if not self.create_backup(svn_path, profile): # Pass profile name self.logger.error("Fetch/merge aborted due to backup failure.") - return + return # Stop the process # --- Fetch and Merge --- self.logger.info(f"Fetching/merging into '{svn_path}' from bundle: {bundle_full_path}") try: + # Call the decoupled command self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) + + # Success message (GitCommands logs details, including merge conflicts) self.logger.info("Changes fetched and potentially merged successfully.") - self.main_frame.show_info("Success", f"Changes fetched successfully into:\n{svn_path}\nfrom bundle:\n{bundle_full_path}") + # Modify success message to acknowledge potential need for manual resolution + self.main_frame.show_info("Fetch Complete", f"Changes fetched successfully into:\n{svn_path}\nfrom bundle:\n{bundle_full_path}\n\nCheck logs for merge status (conflicts may require manual resolution).") except GitCommandError as e: + # Handle specific errors from fetch/merge process self.logger.error(f"Error fetching/merging from Git bundle: {e}") + # Provide specific guidance for conflicts if detected in the error if "merge conflict" in str(e).lower(): self.main_frame.show_error( "Merge Conflict", f"Merge conflict occurred while applying changes from the bundle.\n\n" f"Please resolve the conflicts manually in the repository:\n{svn_path}\n\n" f"After resolving, run 'git add .' and 'git commit' in that directory.\n\n" - f"Original Error: {e}" + f"Original Error details in log." ) else: + # Show generic error for other Git command failures self.main_frame.show_error("Fetch/Merge Error", f"Failed to fetch or merge from bundle:\n{e}") except ValueError as e: + # Handle validation errors (e.g., invalid path) self.logger.error(f"Validation error during fetch/merge: {e}") self.main_frame.show_error("Input Error", f"Invalid input during fetch/merge:\n{e}") except Exception as e: + # Catch unexpected errors self.logger.exception(f"Unexpected error during fetch/merge: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during fetch/merge:\n{e}") def _parse_exclusions(self, profile_name): - """Parses exclusion string from config into sets of extensions and dirs.""" + """ + Parses the exclusion string from config for the given profile. + + 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)) + """ + # Get exclusion string from config exclude_str = self.config_manager.get_profile_option(profile_name, "backup_exclude_extensions", fallback="") excluded_extensions = set() if exclude_str: @@ -534,9 +624,11 @@ class GitSvnSyncApp: if not clean_ext.startswith('.'): clean_ext = '.' + clean_ext excluded_extensions.add(clean_ext) - # Always exclude these directories by default - excluded_dirs = {".git", ".svn"} - self.logger.debug(f"Parsed exclusions - Extensions: {excluded_extensions}, Dirs: {excluded_dirs}") + + # Define standard directories to always exclude + excluded_dirs = {".git", ".svn"} # Case-sensitive on some OS, handle during check + + self.logger.debug(f"Parsed exclusions for '{profile_name}' - Extensions: {excluded_extensions}, Dirs: {excluded_dirs}") return excluded_extensions, excluded_dirs @@ -554,95 +646,122 @@ class GitSvnSyncApp: """ self.logger.info(f"Creating ZIP backup for profile '{profile_name}' from '{source_repo_path}'") + # --- 1. Get and Validate Backup Destination --- backup_base_dir = self.main_frame.backup_dir_var.get().strip() if not backup_base_dir: self.logger.error("Backup failed: Backup directory not specified.") self.main_frame.show_error("Backup Error", "Backup directory is not specified.") return False if not os.path.isdir(backup_base_dir): - self.logger.info(f"Backup directory '{backup_base_dir}' creating...") + self.logger.info(f"Backup directory '{backup_base_dir}' does not exist. Attempting to create...") try: - os.makedirs(backup_base_dir, exist_ok=True) + os.makedirs(backup_base_dir, exist_ok=True) # exist_ok=True prevents error if it already exists + self.logger.info(f"Backup directory created or verified: '{backup_base_dir}'") except OSError as e: - self.logger.error(f"Could not create backup directory '{backup_base_dir}': {e}") + self.logger.error(f"Could not create backup directory '{backup_base_dir}': {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"Could not create backup directory:\n{backup_base_dir}\nError: {e}") return False - # Parse exclusions - excluded_extensions, excluded_dirs = self._parse_exclusions(profile_name) + # --- 2. Parse Exclusions --- + try: + excluded_extensions, excluded_dirs_base = self._parse_exclusions(profile_name) + except Exception as parse_e: + self.logger.error(f"Failed to parse backup exclusions for profile '{profile_name}': {parse_e}", exc_info=True) + self.main_frame.show_error("Backup Error", f"Could not parse backup exclusions for profile '{profile_name}'.\nCheck format in config.\nError: {parse_e}") + return False - # Construct Backup Filename + # --- 3. Construct Backup Filename --- now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + # Sanitize profile name for use in filename safe_profile_name = "".join(c for c in profile_name if c.isalnum() or c in ('_', '-')).rstrip() or "profile" backup_filename = f"{now}_backup_{safe_profile_name}.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 + # --- 4. Create ZIP Archive --- files_added_count = 0 files_excluded_count = 0 dirs_excluded_count = 0 + zip_file = None # Initialize to None try: - with zipfile.ZipFile(backup_full_path, 'w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as zipf: - for root, dirs, files in os.walk(source_repo_path, topdown=True): - # --- Directory Exclusion --- - original_dirs = list(dirs) # Copy before modification - dirs[:] = [d for d in dirs if d not in excluded_dirs] - # Log excluded directories - excluded_in_this_level = set(original_dirs) - set(dirs) - if excluded_in_this_level: - dirs_excluded_count += len(excluded_in_this_level) - for excluded_dir in excluded_in_this_level: - self.logger.debug(f"Excluding directory: {os.path.join(root, excluded_dir)}") + # Open ZIP file for writing + zip_file = zipfile.ZipFile(backup_full_path, 'w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) + # Walk through the source directory + for root, dirs, files in os.walk(source_repo_path, topdown=True): + # --- Directory Exclusion --- + original_dirs_in_level = list(dirs) # Copy before modifying dirs list in-place + # Exclude based on base name (case-insensitive check for robustness) + dirs[:] = [d for d in dirs if d.lower() not in excluded_dirs_base] - # --- File Exclusion and Addition --- - for filename in files: - _, ext = os.path.splitext(filename) - file_ext_lower = ext.lower() + # Log excluded directories for this level + excluded_now = set(original_dirs_in_level) - set(dirs) + if excluded_now: + dirs_excluded_count += len(excluded_now) + for excluded_dir in excluded_now: + self.logger.debug(f"Excluding directory and its contents: {os.path.join(root, excluded_dir)}") - # Combine directory and extension check - parent_dir_name = os.path.basename(root) - # Check if file itself should be excluded by dir name rule (e.g., file named '.git') - is_in_excluded_dir_structure = any(excluded_dir in os.path.normpath(root).split(os.sep) for excluded_dir in excluded_dirs) + # --- File Exclusion and Addition --- + for filename in files: + # Get file extension (lowercase for comparison) + _, ext = os.path.splitext(filename) + file_ext_lower = ext.lower() + # Check if filename itself matches an excluded dir name (e.g. '.git' file) + # or if the file extension is in the exclusion list + if filename.lower() in excluded_dirs_base or file_ext_lower in excluded_extensions: + self.logger.debug(f"Excluding file: {os.path.join(root, filename)}") + files_excluded_count += 1 + continue # Skip this file - if file_ext_lower in excluded_extensions or filename in excluded_dirs or is_in_excluded_dir_structure: - self.logger.debug(f"Excluding file: {os.path.join(root, filename)}") - files_excluded_count += 1 - continue # Skip this file + # Construct full path and archive name (relative path inside ZIP) + file_full_path = os.path.join(root, filename) + # Arcname ensures files are stored with relative paths inside zip + archive_name = os.path.relpath(file_full_path, source_repo_path) - file_full_path = os.path.join(root, filename) - archive_name = os.path.relpath(file_full_path, source_repo_path) + # Add file to zip + try: + zip_file.write(file_full_path, arcname=archive_name) + files_added_count += 1 + # Log progress periodically to avoid flooding logs for large repos + if files_added_count % 500 == 0: + self.logger.debug(f"Added {files_added_count} files to ZIP...") + except Exception as write_e: + self.logger.error(f"Error writing file '{file_full_path}' to ZIP: {write_e}", exc_info=True) + # Option: Raise error to abort backup, or just log and continue + # raise write_e # Uncomment to abort on first write error - try: - zipf.write(file_full_path, arcname=archive_name) - files_added_count += 1 - if files_added_count % 200 == 0: # Log progress less frequently - self.logger.debug(f"Added {files_added_count} files to ZIP...") - except Exception as write_e: - self.logger.error(f"Error writing file '{file_full_path}' to ZIP: {write_e}", exc_info=True) - # Decide whether to continue or abort - - self.logger.info(f"Backup ZIP created: {backup_full_path}") - self.logger.info(f"Files added: {files_added_count}, Files excluded: {files_excluded_count}, Dirs excluded: {dirs_excluded_count}") - return True + # Log final counts after successful walk and write + self.logger.info(f"Backup ZIP creation process finished for: {backup_full_path}") + self.logger.info(f"Summary - Files added: {files_added_count}, Files excluded: {files_excluded_count}, Dirs excluded: {dirs_excluded_count}") + return True # Backup succeeded except OSError as e: - self.logger.error(f"OS error creating backup ZIP: {e}", exc_info=True) - self.main_frame.show_error("Backup Error", f"Error creating backup ZIP:\n{e}") - if os.path.exists(backup_full_path): os.remove(backup_full_path) - return False + self.logger.error(f"OS error during backup ZIP creation: {e}", exc_info=True) + self.main_frame.show_error("Backup Error", f"Error creating backup ZIP:\n{e}") + return False except zipfile.BadZipFile as e: self.logger.error(f"Error related to ZIP file format during backup: {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"ZIP file format error during backup:\n{e}") - if os.path.exists(backup_full_path): os.remove(backup_full_path) return False except Exception as e: - self.logger.exception(f"Unexpected error during ZIP backup creation: {e}") - self.main_frame.show_error("Backup Error", f"An unexpected error occurred during ZIP backup:\n{e}") - if os.path.exists(backup_full_path): os.remove(backup_full_path) - return False + self.logger.exception(f"Unexpected error during ZIP backup creation: {e}") + self.main_frame.show_error("Backup Error", f"An unexpected error occurred during ZIP backup:\n{e}") + return False + finally: + # Ensure the ZIP file is closed even if errors occurred + if zip_file: + zip_file.close() + self.logger.debug(f"ZIP file '{backup_full_path}' closed.") + # Optionally: Clean up partially created/failed zip file + if not os.path.exists(backup_full_path) or (os.path.exists(backup_full_path) and files_added_count == 0): + if os.path.exists(backup_full_path): + try: + os.remove(backup_full_path) + self.logger.warning(f"Removed empty or potentially corrupt backup ZIP: {backup_full_path}") + except OSError as rm_e: + self.logger.error(f"Failed to remove empty/corrupt backup file '{backup_full_path}': {rm_e}") + def manual_backup(self): """Handles the 'Backup Now' button click.""" @@ -653,60 +772,85 @@ class GitSvnSyncApp: self.main_frame.show_error("Backup Error", "No profile selected to perform backup.") return + # Validate SVN Path svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") - if not svn_path: return + if not svn_path: + return # Validation failed + # Save current settings (especially backup dir and exclusions) self.logger.info("Saving current settings before manual backup...") if not self.save_profile_settings(): self.logger.error("Manual Backup: Could not save profile settings. Backup may use outdated 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).\nContinue backup with previously saved settings?"): self.logger.warning("Manual backup aborted by user due to save failure.") return + # Call the create_backup method (which now creates ZIP) self.logger.info(f"Starting manual backup for profile '{profile}'...") - # Call the ZIP backup method - success = self.create_backup(svn_path, profile) + success = self.create_backup(svn_path, profile) # Pass svn path and profile name + # Show result message if success: self.main_frame.show_info("Backup Complete", f"Manual ZIP backup for profile '{profile}' completed successfully.") else: - self.logger.error(f"Manual backup failed for profile '{profile}'.") - # Error message already shown by create_backup + # Error message should have been shown by create_backup + self.logger.error(f"Manual backup failed for profile '{profile}'. See logs for details.") + # Optionally show a redundant error here if needed + # self.main_frame.show_error("Backup Failed", f"Manual backup failed for profile '{profile}'. See logs.") + # --- GUI State Utilities --- def _clear_and_disable_fields(self): """Clears repository config fields and disables action buttons.""" if hasattr(self, 'main_frame'): + # Clear repository fields self.main_frame.svn_path_entry.delete(0, tk.END) self.main_frame.usb_path_entry.delete(0, tk.END) self.main_frame.bundle_name_entry.delete(0, tk.END) self.main_frame.bundle_updated_name_entry.delete(0, tk.END) self.main_frame.autocommit_var.set(False) self.main_frame.commit_message_var.set("") # Clear commit message - # Don't clear backup fields, keep defaults? Or load default profile settings? - # Reset SVN indicator - self.update_svn_status_indicator("") - # Disable action buttons - self._disable_function_buttons() + # Optionally clear backup fields or load defaults? For now, leave as they are. + # self.main_frame.backup_exclude_extensions_var.set(".log,.tmp") # Reset exclude? + + # Reset SVN indicator and related buttons (Prepare, Edit Gitignore) + self.update_svn_status_indicator("") # Pass empty path + # Disable other action buttons + self._disable_function_buttons(disable_prepare_gitignore=False) # Let update_svn_status handle prepare/edit self.logger.debug("GUI fields cleared/reset and action buttons disabled.") - def _disable_function_buttons(self): - """Disables the main action buttons.""" + + def _disable_function_buttons(self, disable_prepare_gitignore=True): + """ + Disables the main action buttons. + Args: + disable_prepare_gitignore (bool): If True, also disables Prepare and Edit Gitignore buttons. + """ if hasattr(self, 'main_frame'): buttons = [ - getattr(self.main_frame, 'prepare_svn_button', None), getattr(self.main_frame, 'create_bundle_button', None), getattr(self.main_frame, 'fetch_bundle_button', None), - getattr(self.main_frame, 'manual_backup_button', None) # Also disable manual backup + getattr(self.main_frame, 'manual_backup_button', None) ] + if disable_prepare_gitignore: + buttons.extend([ + getattr(self.main_frame, 'prepare_svn_button', None), + getattr(self.main_frame, 'edit_gitignore_button', None) + ]) + for button in buttons: if button: button.config(state=tk.DISABLED) - self.logger.debug("Function buttons disabled.") + self.logger.debug(f"Function buttons disabled (disable_prepare_gitignore={disable_prepare_gitignore}).") + def _enable_function_buttons(self): - """Enables action buttons based on profile/path validity. Prepare button state depends on repo status.""" + """ + Enables action buttons based on profile/path validity. + Prepare/Edit Gitignore button state depends on repo status/path validity. + """ if hasattr(self, 'main_frame'): - # Enable Create, Fetch, and Manual Backup if a profile is loaded and paths likely valid + # Enable Create, Fetch, and Manual Backup if a profile is loaded general_state = tk.NORMAL # Assume enabled if profile is loaded buttons_to_enable = [ getattr(self.main_frame, 'create_bundle_button', None), @@ -716,36 +860,48 @@ class GitSvnSyncApp: for button in buttons_to_enable: if button: button.config(state=general_state) - # Prepare button state is handled separately by update_svn_indicator + # Trigger update for Prepare and Edit .gitignore based on current SVN path validity self.update_svn_status_indicator(self.main_frame.svn_path_entry.get()) - self.logger.debug("Create/Fetch/Backup buttons enabled. Prepare button state updated.") + self.logger.debug("Create/Fetch/Backup buttons enabled. Prepare/Edit Gitignore state updated.") + def show_fatal_error(self, message): """Shows a fatal error message before the app potentially exits.""" try: # Try to show graphical error - messagebox.showerror("Fatal Error", message) + messagebox.showerror("Fatal Error", message, parent=self.master if self.master.winfo_exists() else None) except tk.TclError: # Fallback if GUI is not ready print(f"FATAL ERROR: {message}") + except Exception as e: + print(f"FATAL ERROR (and error showing message box: {e}): {message}") # --- Application Entry Point --- def main(): """Main function to create the Tkinter root window and run the application.""" root = tk.Tk() - root.minsize(650, 550) # Adjust min size slightly for new fields + # Adjust min size slightly for new fields/buttons + root.minsize(700, 600) # Increased width for better layout + app = None # Initialize app variable try: app = GitSvnSyncApp(root) - # Check if initialization failed early + # Check if initialization failed early (e.g., config load failure) if hasattr(app, 'main_frame'): # Check if GUI was likely initialized root.mainloop() else: - print("Application initialization failed, exiting.") + print("Application initialization failed before GUI setup, exiting.") except Exception as e: + # Catch-all for truly unexpected errors during App init or main loop logging.exception("Fatal error during application startup or main loop.") # Try showing message box, fallback to print try: - messagebox.showerror("Fatal Error", f"Application failed unexpectedly:\n{e}") - except Exception: - print(f"FATAL ERROR: Application failed unexpectedly:\n{e}") + # Check if root window exists before showing message box relative to it + 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: + print(f"FATAL ERROR (and error showing message box: {msg_e}): Application failed unexpectedly:\n{e}") + finally: + # Ensure cleanup or logging on exit if needed + logging.info("Application exiting.") + if __name__ == "__main__": main() \ No newline at end of file diff --git a/gui.py b/gui.py index d87833b..8f4eebd 100644 --- a/gui.py +++ b/gui.py @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk, scrolledtext, filedialog, messagebox, simpledialog import logging -import os # Keep import +import os # Import os for path operations # Import constant from the central location from config_manager import DEFAULT_BACKUP_DIR @@ -22,16 +22,18 @@ class Tooltip: self.hidetip() # Hide any existing tooltip first if not self.widget.winfo_exists(): return # Avoid error if widget destroyed try: - x, y, _, _ = self.widget.bbox("insert") # Get widget location - x += self.widget.winfo_rootx() + 25 # Position tooltip slightly below and right + # Get widget position relative to screen + x, y, _, _ = self.widget.bbox("insert") # Get widget location relative to widget itself + x += self.widget.winfo_rootx() + 25 # Add screen coordinates and offset y += self.widget.winfo_rooty() + 25 except tk.TclError: # Handle cases where bbox might fail (e.g., widget not visible) + # Fallback position calculation x = self.widget.winfo_rootx() + self.widget.winfo_width() // 2 y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5 self.tooltip_window = tw = tk.Toplevel(self.widget) # Create new top-level window tw.wm_overrideredirect(True) # Remove window decorations (border, title bar) - tw.wm_geometry(f"+{x}+{y}") # Position the window + tw.wm_geometry(f"+{int(x)}+{int(y)}") # Position the window (ensure integer coordinates) label = tk.Label(tw, text=self.text, justify=tk.LEFT, background="#ffffe0", relief=tk.SOLID, borderwidth=1, # Light yellow background font=("tahoma", "8", "normal")) @@ -42,10 +44,209 @@ class Tooltip: tw = self.tooltip_window self.tooltip_window = None if tw: - tw.destroy() + try: + if tw.winfo_exists(): + tw.destroy() + except tk.TclError: # Handle cases where window might already be destroyed + pass # --- End Tooltip Class --- +# --- Gitignore Editor Window Class --- +class GitignoreEditorWindow(tk.Toplevel): + """ + A Toplevel window for editing the .gitignore file. + """ + def __init__(self, master, gitignore_path, logger): + """ + Initializes the editor window. + + Args: + master (tk.Widget): The parent widget (usually the main app window). + gitignore_path (str): The full path to the .gitignore file. + logger (logging.Logger): Logger instance for logging actions. + """ + super().__init__(master) + self.gitignore_path = gitignore_path + self.logger = logger + self.original_content = "" # Store original content to check for changes + + # --- Window Configuration --- + self.title(f"Edit {os.path.basename(gitignore_path)}") + self.geometry("600x450") # Set initial size, slightly larger height + self.minsize(400, 300) # Set minimum size + # Make window modal (grab focus) + self.grab_set() + # Make window appear on top of the master window + self.transient(master) + # Handle closing via window manager (X button) + self.protocol("WM_DELETE_WINDOW", self._on_close) + + # --- Widgets --- + # Main frame + main_frame = ttk.Frame(self, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + # Configure grid weights for resizing + main_frame.rowconfigure(0, weight=1) # Text editor row expands + main_frame.columnconfigure(0, weight=1) # Text editor column expands + + # ScrolledText widget for editing + self.text_editor = scrolledtext.ScrolledText( + main_frame, + wrap=tk.WORD, + font=("Consolas", 10), # Use a suitable font + undo=True # Enable undo/redo + ) + self.text_editor.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) # Use grid, expand in all directions + + # Button frame (using grid within main_frame) + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=1, column=0, sticky="ew") # Place below text editor, stretch horizontally + # Center buttons within the button frame + button_frame.columnconfigure(0, weight=1) # Make space on the left + button_frame.columnconfigure(3, weight=1) # Make space on the right + + # Save button + self.save_button = ttk.Button(button_frame, text="Save and Close", command=self._save_and_close) + self.save_button.grid(row=0, column=2, padx=5) # Place in middle-right column + + # Cancel button + self.cancel_button = ttk.Button(button_frame, text="Cancel", command=self._on_close) + self.cancel_button.grid(row=0, column=1, padx=5) # Place in middle-left column + + # --- Load File Content --- + self._load_file() + + # Center window relative to parent (call after widgets are created) + self._center_window(master) + + # Set focus to the text editor + self.text_editor.focus_set() + + + def _center_window(self, parent): + """Centers the window relative to its parent.""" + self.update_idletasks() # Ensure window size is calculated + parent_x = parent.winfo_rootx() + parent_y = parent.winfo_rooty() + parent_width = parent.winfo_width() + parent_height = parent.winfo_height() + win_width = self.winfo_width() + win_height = self.winfo_height() + + # Calculate position, ensuring it stays within screen bounds (basic check) + x_pos = parent_x + (parent_width // 2) - (win_width // 2) + y_pos = parent_y + (parent_height // 2) - (win_height // 2) + + # Adjust if going off-screen (simple version) + 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)) + + 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: + if os.path.exists(self.gitignore_path): + with open(self.gitignore_path, 'r', encoding='utf-8', errors='replace') as f: + self.original_content = f.read() + self.text_editor.delete("1.0", tk.END) # Clear previous content + self.text_editor.insert(tk.END, self.original_content) + self.text_editor.edit_reset() # Reset undo stack after loading + self.logger.debug(".gitignore content loaded successfully.") + else: + self.logger.info(f"'{self.gitignore_path}' does not exist. Editor is empty.") + self.original_content = "" + self.text_editor.delete("1.0", tk.END) + self.text_editor.edit_reset() + except IOError as e: + 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) + except Exception as e: + self.logger.exception(f"Unexpected error loading {self.gitignore_path}: {e}") + messagebox.showerror("Unexpected Error", + f"An unexpected error occurred while loading the file:\n{e}", + parent=self) + + + def _save_file(self): + """Saves the current content of the editor to the .gitignore file.""" + # Get content, ensure it ends with a single newline if not empty + current_content = self.text_editor.get("1.0", tk.END).rstrip() + if current_content: + current_content += "\n" + + # Normalize original content similarly for comparison + normalized_original = self.original_content.rstrip() + if normalized_original: + normalized_original += "\n" + + if current_content == normalized_original: + self.logger.info("No changes detected in .gitignore content. Skipping save.") + return True # Indicate success (no save needed) + + self.logger.info(f"Saving changes to: {self.gitignore_path}") + try: + # Ensure directory exists before writing (though unlikely needed for .gitignore) + # os.makedirs(os.path.dirname(self.gitignore_path), exist_ok=True) + with open(self.gitignore_path, 'w', encoding='utf-8', newline='\n') as f: # Use newline='\n' for consistency + f.write(current_content) + self.logger.info(".gitignore file saved successfully.") + self.original_content = current_content # Update original content after save + self.text_editor.edit_reset() # Reset undo stack after saving + return True # Indicate success + except IOError as e: + 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 failure + except Exception as e: + self.logger.exception(f"Unexpected error saving {self.gitignore_path}: {e}") + messagebox.showerror("Unexpected Error", + f"An unexpected error occurred while saving the file:\n{e}", + parent=self) + return False + + + def _save_and_close(self): + """Saves the file and closes the window if save is successful.""" + if self._save_file(): + self.destroy() # Close window only if save succeeded or no changes + + + def _on_close(self): + """Handles closing the window (Cancel button or X button).""" + # Check if content changed + current_content = self.text_editor.get("1.0", tk.END).rstrip() + if current_content: current_content += "\n" + normalized_original = self.original_content.rstrip() + if normalized_original: normalized_original += "\n" + + if current_content != normalized_original: + # Use askyesnocancel for three options + response = messagebox.askyesnocancel("Unsaved Changes", + "You have unsaved changes.\nSave before closing?", + parent=self) + if response is True: # Yes, save and close + self._save_and_close() # This handles save status and closes if successful + elif response is False: # No, discard and close + self.logger.warning("Discarding unsaved changes in .gitignore editor.") + self.destroy() + # Else (Cancel): Do nothing, keep window open + else: + # No changes, just close + self.destroy() + +# --- End Gitignore Editor Window --- + + class MainFrame(ttk.Frame): """ The main frame containing all GUI elements for the Git SVN Sync Tool. @@ -59,23 +260,25 @@ class MainFrame(ttk.Frame): def __init__(self, master, load_profile_settings_cb, browse_folder_cb, update_svn_status_cb, prepare_svn_for_git_cb, create_git_bundle_cb, fetch_from_git_bundle_cb, config_manager_instance, profile_sections_list, - add_profile_cb, remove_profile_cb, manual_backup_cb): # Added callback + add_profile_cb, remove_profile_cb, manual_backup_cb, + open_gitignore_editor_cb): # Added callback """ Initializes the MainFrame. Args: master (tk.Tk or ttk.Frame): The parent widget. - load_profile_settings_cb (callable): Called when profile selection changes. Signature: func(profile_name) - browse_folder_cb (callable): Called by Browse buttons. Signature: func(entry_widget_to_update) - update_svn_status_cb (callable): Called when SVN path might change. Signature: func(svn_path) - prepare_svn_for_git_cb (callable): Called by 'Prepare SVN' button. Signature: func() - create_git_bundle_cb (callable): Called by 'Create Bundle' button. Signature: func() - fetch_from_git_bundle_cb (callable): Called by 'Fetch Bundle' button. Signature: func() - config_manager_instance (ConfigManager): Instance to access config data if needed. - profile_sections_list (list): Initial list of profile names for the dropdown. - add_profile_cb (callable): Called by 'Add Profile' button. Signature: func() - remove_profile_cb (callable): Called by 'Remove Profile' button. Signature: func() - manual_backup_cb (callable): Called by 'Backup Now' button. Signature: func() + load_profile_settings_cb (callable): Called when profile selection changes. + browse_folder_cb (callable): Called by Browse buttons. + update_svn_status_cb (callable): Called when SVN path might change. + prepare_svn_for_git_cb (callable): Called by 'Prepare SVN' button. + create_git_bundle_cb (callable): Called by 'Create Bundle' button. + fetch_from_git_bundle_cb (callable): Called by 'Fetch Bundle' button. + config_manager_instance (ConfigManager): Instance to access config data. + profile_sections_list (list): Initial list of profile names. + add_profile_cb (callable): Called by 'Add Profile' button. + remove_profile_cb (callable): Called by 'Remove Profile' button. + manual_backup_cb (callable): Called by 'Backup Now' button. + open_gitignore_editor_cb (callable): Called by 'Edit .gitignore' button. """ super().__init__(master) self.master = master @@ -89,7 +292,8 @@ class MainFrame(ttk.Frame): self.fetch_from_git_bundle_callback = fetch_from_git_bundle_cb self.add_profile_callback = add_profile_cb self.remove_profile_callback = remove_profile_cb - self.manual_backup_callback = manual_backup_cb # Store manual backup callback + self.manual_backup_callback = manual_backup_cb + self.open_gitignore_editor_callback = open_gitignore_editor_cb # Store callback # Store config manager and initial profiles if needed locally self.config_manager = config_manager_instance @@ -97,7 +301,7 @@ class MainFrame(ttk.Frame): # Style configuration self.style = ttk.Style() - self.style.theme_use('clam') + self.style.theme_use('clam') # Example theme # Pack the main frame self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=10) @@ -112,7 +316,7 @@ class MainFrame(ttk.Frame): # --- Widget Creation --- self._create_profile_frame() - self._create_repo_frame() + self._create_repo_frame() # Modified self._create_backup_frame() self._create_function_frame() self._create_log_area() @@ -122,6 +326,7 @@ class MainFrame(ttk.Frame): self.toggle_backup_dir() # Initial status update is handled by the controller after loading profile + def _create_profile_frame(self): """Creates the frame for profile selection and management.""" self.profile_frame = ttk.LabelFrame(self, text="Profile Configuration", padding=(10, 5)) @@ -132,22 +337,26 @@ class MainFrame(ttk.Frame): self.profile_dropdown = ttk.Combobox( self.profile_frame, textvariable=self.profile_var, - state="readonly", - width=35, - values=self.initial_profile_sections + state="readonly", # Prevent typing custom values + width=35, # Adjust width as needed + values=self.initial_profile_sections # Set initial values ) - self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5) + self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5) # EW = stretch horizontally + # When selection changes, call the controller's load function self.profile_dropdown.bind("<>", lambda event: self.load_profile_settings_callback(self.profile_var.get())) + # Also trace the variable for programmatic changes self.profile_var.trace_add("write", lambda *args: self.load_profile_settings_callback(self.profile_var.get())) + # Profile management buttons self.add_profile_button = ttk.Button(self.profile_frame, text="Add", width=5, command=self.add_profile_callback) self.add_profile_button.grid(row=0, column=2, sticky=tk.W, padx=(5, 0), pady=5) self.remove_profile_button = ttk.Button(self.profile_frame, text="Remove", width=8, command=self.remove_profile_callback) self.remove_profile_button.grid(row=0, column=3, sticky=tk.W, padx=(2, 5), pady=5) + # Allow the dropdown column to expand horizontally self.profile_frame.columnconfigure(1, weight=1) @@ -155,73 +364,101 @@ class MainFrame(ttk.Frame): """Creates the frame for repository paths, bundle names, and commit message.""" self.repo_frame = ttk.LabelFrame(self, text="Repository Configuration", padding=(10, 5)) self.repo_frame.pack(pady=5, fill="x") - col_entry_span = 2 # Span for entry widgets if browse/indicator are present - col_browse = col_entry_span # Column index for browse buttons - col_indicator = col_browse + 1 # Column index for indicator + # Define column indices for clarity and easier adjustment + col_label = 0 + col_entry = 1 + col_entry_span = 1 # Entry widgets usually span 1 logical column + col_button1 = col_entry + col_entry_span # Column index for first button (e.g., Browse) + col_button2 = col_button1 + 1 # Column index for second button (e.g., Edit .gitignore) + col_indicator = col_button2 + 1 # Column index for status indicator (at the far right) + + # Configure grid columns weights + self.repo_frame.columnconfigure(col_entry, weight=1) # Allow main entry fields to expand # Row 0: SVN Path - ttk.Label(self.repo_frame, text="SVN Working Copy:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) + ttk.Label(self.repo_frame, text="SVN Working Copy:").grid(row=0, column=col_label, sticky=tk.W, padx=5, pady=3) self.svn_path_entry = ttk.Entry(self.repo_frame, width=60) - self.svn_path_entry.grid(row=0, column=1, columnspan=col_entry_span, sticky=tk.EW, padx=5, pady=3) # Span entry + # Span entry up to the first button column + self.svn_path_entry.grid(row=0, column=col_entry, columnspan=col_button1 - col_entry, sticky=tk.EW, padx=5, pady=3) 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_browse_button = ttk.Button( self.repo_frame, text="Browse...", width=9, command=lambda: self.browse_folder_callback(self.svn_path_entry) ) - self.svn_path_browse_button.grid(row=0, column=col_browse, sticky=tk.W, padx=(0, 5), pady=3) + # Place browse button in its designated column + self.svn_path_browse_button.grid(row=0, column=col_button1, sticky=tk.W, padx=(0, 5), pady=3) + # Place indicator at the far right self.svn_status_indicator = tk.Label(self.repo_frame, text="", width=2, height=1, relief=tk.SUNKEN, background=self.RED, anchor=tk.CENTER) self.svn_status_indicator.grid(row=0, column=col_indicator, sticky=tk.E, padx=(0, 5), pady=3) self.create_tooltip(self.svn_status_indicator, "Indicates if '.git' folder exists (Green=Yes, Red=No)") # Row 1: USB/Bundle Target Path - ttk.Label(self.repo_frame, text="Bundle Target Dir:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3) + ttk.Label(self.repo_frame, text="Bundle Target Dir:").grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=3) self.usb_path_entry = ttk.Entry(self.repo_frame, width=60) - self.usb_path_entry.grid(row=1, column=1, columnspan=col_entry_span, sticky=tk.EW, padx=5, pady=3) # Span entry + # Span entry up to the browse button + self.usb_path_entry.grid(row=1, column=col_entry, columnspan=col_button1 - col_entry, sticky=tk.EW, padx=5, pady=3) self.usb_path_browse_button = ttk.Button( self.repo_frame, text="Browse...", width=9, command=lambda: self.browse_folder_callback(self.usb_path_entry) ) - self.usb_path_browse_button.grid(row=1, column=col_browse, sticky=tk.W, padx=(0, 5), pady=3) + self.usb_path_browse_button.grid(row=1, column=col_button1, sticky=tk.W, padx=(0, 5), pady=3) # Row 2: Create Bundle Name - ttk.Label(self.repo_frame, text="Create Bundle Name:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=3) + ttk.Label(self.repo_frame, text="Create Bundle Name:").grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=3) self.bundle_name_entry = ttk.Entry(self.repo_frame, width=60) - self.bundle_name_entry.grid(row=2, column=1, columnspan=col_browse, sticky=tk.EW, padx=5, pady=3) # Span entry+browse cols + # Span entry across its column and button columns if needed (up to indicator) + self.bundle_name_entry.grid(row=2, column=col_entry, columnspan=(col_indicator - col_entry), sticky=tk.EW, padx=5, pady=3) # Row 3: Fetch Bundle Name - ttk.Label(self.repo_frame, text="Fetch Bundle Name:").grid(row=3, column=0, sticky=tk.W, padx=5, pady=3) + ttk.Label(self.repo_frame, text="Fetch Bundle Name:").grid(row=3, column=col_label, sticky=tk.W, padx=5, pady=3) self.bundle_updated_name_entry = ttk.Entry(self.repo_frame, width=60) - self.bundle_updated_name_entry.grid(row=3, column=1, columnspan=col_browse, sticky=tk.EW, padx=5, pady=3) # Span + self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=(col_indicator - col_entry), sticky=tk.EW, padx=5, pady=3) - # Row 4: Commit Message - ttk.Label(self.repo_frame, text="Commit Message:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=3) + # Row 4: Commit Message + Edit Button + ttk.Label(self.repo_frame, text="Commit Message:").grid(row=4, column=col_label, sticky=tk.W, padx=5, pady=3) self.commit_message_entry = ttk.Entry( self.repo_frame, - textvariable=self.commit_message_var, # Use Tkinter variable + textvariable=self.commit_message_var, width=60 ) - self.commit_message_entry.grid(row=4, column=1, columnspan=col_browse, sticky=tk.EW, padx=5, pady=3) # Span + # Span entry up to the first button column + self.commit_message_entry.grid(row=4, column=col_entry, columnspan=col_button1 - col_entry, sticky=tk.EW, padx=5, pady=3) self.create_tooltip(self.commit_message_entry, "Optional message for autocommit. If empty, a default message is used.") + # Edit .gitignore Button + self.edit_gitignore_button = ttk.Button( + self.repo_frame, + text="Edit .gitignore", + width=12, # Adjust width as needed + command=self.open_gitignore_editor_callback, # Use the new callback + state=tk.DISABLED # Initially disabled, enabled by controller + ) + # Place button next to commit message entry, in the first button column + self.edit_gitignore_button.grid(row=4, column=col_button1, sticky=tk.W, padx=(0, 5), pady=3) + self.create_tooltip(self.edit_gitignore_button, "Open editor for the .gitignore file in the SVN Working Copy.") + # Row 5: Autocommit Checkbox self.autocommit_checkbox = ttk.Checkbutton( self.repo_frame, text="Autocommit changes before 'Create Bundle'", variable=self.autocommit_var ) - self.autocommit_checkbox.grid(row=5, column=0, columnspan=col_indicator + 1, sticky=tk.W, padx=5, pady=(5, 3)) # Span all columns - - # Configure column weights for horizontal resizing - self.repo_frame.columnconfigure(1, weight=1) # Allow entry fields (column 1) to expand + # Span across all columns used, up to and including indicator column + self.autocommit_checkbox.grid(row=5, column=0, columnspan=col_indicator + 1, sticky=tk.W, padx=5, pady=(5, 3)) def _create_backup_frame(self): """Creates the frame for backup configuration including exclusions.""" self.backup_frame = ttk.LabelFrame(self, text="Backup Configuration (ZIP)", padding=(10, 5)) self.backup_frame.pack(pady=5, fill="x") - col_entry_span = 1 # Default span for entry - col_browse = 2 # Column for browse button + # Define columns + col_label = 0 + col_entry = 1 + col_button = 2 + + # Configure column weights + self.backup_frame.columnconfigure(col_entry, weight=1) # Allow entry fields to expand # Row 0: Autobackup Checkbox self.autobackup_checkbox = ttk.Checkbutton( @@ -230,41 +467,38 @@ class MainFrame(ttk.Frame): variable=self.autobackup_var, command=self.toggle_backup_dir ) - self.autobackup_checkbox.grid(row=0, column=0, columnspan=col_browse + 1, sticky=tk.W, padx=5, pady=(5, 0)) + self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=col_button + 1, sticky=tk.W, padx=5, pady=(5, 0)) # Row 1: Backup Directory self.backup_dir_label = ttk.Label(self.backup_frame, text="Backup Directory:") - self.backup_dir_label.grid(row=1, column=0, sticky=tk.W, padx=5, pady=5) + self.backup_dir_label.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=5) self.backup_dir_entry = ttk.Entry( self.backup_frame, textvariable=self.backup_dir_var, width=60, state=tk.DISABLED ) - self.backup_dir_entry.grid(row=1, column=1, columnspan=col_entry_span, sticky=tk.EW, padx=5, pady=5) + self.backup_dir_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=5) self.backup_dir_button = ttk.Button( self.backup_frame, text="Browse...", width=9, command=self.browse_backup_dir, state=tk.DISABLED ) - self.backup_dir_button.grid(row=1, column=col_browse, sticky=tk.W, padx=(0, 5), pady=5) + self.backup_dir_button.grid(row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=5) # Row 2: Exclude Extensions self.backup_exclude_label = ttk.Label(self.backup_frame, text="Exclude Extensions:") - self.backup_exclude_label.grid(row=2, column=0, sticky=tk.W, padx=5, pady=5) + self.backup_exclude_label.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=5) self.backup_exclude_entry = ttk.Entry( self.backup_frame, textvariable=self.backup_exclude_extensions_var, # Use Tkinter variable width=60 ) - # Span across entry and browse columns - self.backup_exclude_entry.grid(row=2, column=1, columnspan=col_entry_span + (col_browse - 1), sticky=tk.EW, padx=5, pady=5) + # Span across entry and button columns + self.backup_exclude_entry.grid(row=2, column=col_entry, columnspan=col_button - col_entry + 1, sticky=tk.EW, padx=5, pady=5) self.create_tooltip(self.backup_exclude_entry, "Comma-separated extensions to exclude (e.g., .log, .tmp, .bak)") - # Configure column weights - self.backup_frame.columnconfigure(1, weight=1) # Allow entry fields to expand - def _create_function_frame(self): """Creates the frame holding the main action buttons.""" @@ -310,16 +544,17 @@ class MainFrame(ttk.Frame): def _create_log_area(self): """Creates the scrolled text area for logging output.""" - log_frame = ttk.Frame(self.master) + log_frame = ttk.Frame(self.master) # Attach to master, below MainFrame content log_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=10, pady=(5, 10)) + # ScrolledText widget for log messages self.log_text = scrolledtext.ScrolledText( log_frame, - height=12, - width=100, - font=("Consolas", 9), - wrap=tk.WORD, - state=tk.DISABLED + height=12, # Adjust height as desired + width=100, # Adjust width as desired + font=("Consolas", 9), # Use a monospaced font like Consolas or Courier New + wrap=tk.WORD, # Wrap lines at word boundaries + state=tk.DISABLED # Start in read-only state ) self.log_text.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) @@ -334,9 +569,10 @@ class MainFrame(ttk.Frame): if DEFAULT_PROFILE in self.initial_profile_sections: self.profile_var.set(DEFAULT_PROFILE) - elif self.initial_profile_sections: + elif self.initial_profile_sections: # If default not found, select the first available self.profile_var.set(self.initial_profile_sections[0]) - # else: variable remains empty + # else: No profiles exist, variable remains empty + # --- GUI Update Methods --- @@ -350,24 +586,29 @@ class MainFrame(ttk.Frame): if hasattr(self, 'backup_dir_button'): self.backup_dir_button.config(state=new_state) # Exclude entry state is independent of autobackup checkbox - # if hasattr(self, 'backup_exclude_entry'): - # self.backup_exclude_entry.config(state=tk.NORMAL) # Always editable def browse_backup_dir(self): """Opens a directory selection dialog for the backup directory entry.""" - initial_dir = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR + # Suggest initial directory based on current entry or the default + initial_dir = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR # Use IMPORTED constant dirname = filedialog.askdirectory( initialdir=initial_dir, title="Select Backup Directory", - parent=self.master + parent=self.master # Ensure dialog is modal to the main window ) - if dirname: + if dirname: # Only update if a directory was actually selected self.backup_dir_var.set(dirname) def update_svn_indicator(self, is_prepared): - """Updates the visual indicator and 'Prepare' button state.""" + """ + Updates the visual indicator (color) for SVN preparation status and toggles + the 'Prepare' button state accordingly. (Edit gitignore button state is handled separately) + + Args: + is_prepared (bool): True if the SVN repo has a '.git' directory, False otherwise. + """ if is_prepared: indicator_color = self.GREEN prepare_button_state = tk.DISABLED @@ -377,34 +618,47 @@ class MainFrame(ttk.Frame): prepare_button_state = tk.NORMAL tooltip_text = "Repository not prepared ('.git' not found)" + # Update indicator color if hasattr(self, 'svn_status_indicator'): self.svn_status_indicator.config(background=indicator_color) self.update_tooltip(self.svn_status_indicator, tooltip_text) + + # Update prepare button state if hasattr(self, 'prepare_svn_button'): self.prepare_svn_button.config(state=prepare_button_state) def update_profile_dropdown(self, sections): - """Updates the list of profiles shown in the combobox.""" - if hasattr(self, 'profile_dropdown'): + """ + Updates the list of profiles shown in the combobox. + + Args: + sections (list): The new list of profile names. + """ + if hasattr(self, 'profile_dropdown'): # Check if dropdown exists current_profile = self.profile_var.get() self.profile_dropdown['values'] = sections - # Try to maintain selection + + # Maintain selection if possible, otherwise select default or first, or clear if sections: if current_profile in sections: - self.profile_var.set(current_profile) + self.profile_var.set(current_profile) # Keep current selection elif "default" in sections: - self.profile_var.set("default") + self.profile_var.set("default") # Select default if available else: - self.profile_var.set(sections[0]) + self.profile_var.set(sections[0]) # Select the first available else: - self.profile_var.set("") - # self.profile_dropdown.event_generate("<>") # Not always needed + self.profile_var.set("") # No profiles left, clear selection + # Optionally trigger the callback if needed after programmatic change + # self.load_profile_settings_callback(self.profile_var.get()) # --- Dialog Wrappers --- + # These provide a consistent way to show standard dialogs via the GUI frame + def ask_new_profile_name(self): """Asks the user for a new profile name using a simple dialog.""" + # parent=self.master makes the dialog modal to the main window return simpledialog.askstring("Add Profile", "Enter new profile name:", parent=self.master) def show_error(self, title, message): @@ -425,14 +679,15 @@ class MainFrame(ttk.Frame): # --- Tooltip Helper Methods --- + # Simple tooltip implementation for GUI elements + def create_tooltip(self, widget, text): """Creates a tooltip for a given widget.""" tooltip = Tooltip(widget, text) + # Use add='+' to avoid overwriting other bindings widget.bind("", lambda event, tt=tooltip: tt.showtip(), add='+') widget.bind("", lambda event, tt=tooltip: tt.hidetip(), add='+') widget.bind("", lambda event, tt=tooltip: tt.hidetip(), add='+') # Hide on click - # Store tooltip instance if needed for update_tooltip - # setattr(widget, '_tooltip_instance', tooltip) def update_tooltip(self, widget, text): @@ -441,9 +696,4 @@ class MainFrame(ttk.Frame): widget.unbind("") widget.unbind("") widget.unbind("") - self.create_tooltip(widget, text) - # More complex approach: find stored instance and update its text - # if hasattr(widget, '_tooltip_instance'): - # widget._tooltip_instance.text = text - # else: - # self.create_tooltip(widget, text) \ No newline at end of file + self.create_tooltip(widget, text) \ No newline at end of file