Autocommit profile 'GitUtility'

This commit is contained in:
VALLONGOL 2025-04-07 12:30:51 +02:00
parent 27a48b6e2f
commit 9e6433bd52
2 changed files with 605 additions and 199 deletions

View File

@ -11,7 +11,8 @@ import zipfile # Import zipfile module
from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR
from git_commands import GitCommands, GitCommandError from git_commands import GitCommands, GitCommandError
from logger_config import setup_logger 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: class GitSvnSyncApp:
""" """
@ -49,7 +50,7 @@ class GitSvnSyncApp:
master, master,
load_profile_settings_cb=self.load_profile_settings, load_profile_settings_cb=self.load_profile_settings,
browse_folder_cb=self.browse_folder, 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, prepare_svn_for_git_cb=self.prepare_svn_for_git,
create_git_bundle_cb=self.create_git_bundle, create_git_bundle_cb=self.create_git_bundle,
fetch_from_git_bundle_cb=self.fetch_from_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(), profile_sections_list=self.config_manager.get_profile_sections(),
add_profile_cb=self.add_profile, add_profile_cb=self.add_profile,
remove_profile_cb=self.remove_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: except Exception as e:
self.logger.critical(f"Failed to initialize MainFrame GUI: {e}", exc_info=True) 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 # The trace on profile_var in MainFrame should trigger load_profile_settings automatically
else: else:
self.logger.warning("No profile selected on startup. Disabling action buttons.") 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.") self.logger.info("Application started successfully.")
@ -89,8 +91,11 @@ class GitSvnSyncApp:
"""Handles the event when the user tries to close the window.""" """Handles the event when the user tries to close the window."""
self.logger.info("Close button clicked. Preparing to exit.") self.logger.info("Close button clicked. Preparing to exit.")
# Add checks for unsaved changes or running ops if necessary # 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.logger.info("Application closing.")
self.master.destroy() self.master.destroy() # Close the Tkinter window
# --- Profile Management Callbacks/Methods --- # --- 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.backup_exclude_extensions_var.set(exclude_extensions) # Update exclude extensions entry
self.main_frame.toggle_backup_dir() # Update backup dir entry state 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) self.update_svn_status_indicator(svn_path)
# Enable function buttons now that a valid profile is loaded # 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}'.") self.logger.info(f"Settings loaded successfully for profile '{profile_name}'.")
else: else:
self.logger.error("Main frame not available, cannot load settings into GUI.") self.logger.error("Main frame not available, cannot load settings into GUI.")
@ -251,11 +256,14 @@ class GitSvnSyncApp:
try: try:
success = self.config_manager.remove_profile_section(profile_to_remove) success = self.config_manager.remove_profile_section(profile_to_remove)
if success: 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.") 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() updated_sections = self.config_manager.get_profile_sections()
self.main_frame.update_profile_dropdown(updated_sections) self.main_frame.update_profile_dropdown(updated_sections)
# Loading settings for the new selection will be triggered by trace
else: else:
# ConfigManager already logged the specific reason
self.main_frame.show_error("Error", f"Failed to remove profile '{profile_to_remove}'. Check logs.") self.main_frame.show_error("Error", f"Failed to remove profile '{profile_to_remove}'. Check logs.")
except Exception as e: except Exception as e:
@ -270,20 +278,22 @@ class GitSvnSyncApp:
def browse_folder(self, entry_widget): def browse_folder(self, entry_widget):
"""Opens a folder selection dialog and updates the specified Tkinter 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}") 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("~") 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("~") initial_dir = os.path.expanduser("~")
directory = filedialog.askdirectory( directory = filedialog.askdirectory(
initialdir=initial_dir, initialdir=initial_dir,
title="Select Directory", 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}") self.logger.debug(f"Directory selected: {directory}")
entry_widget.delete(0, tk.END) entry_widget.delete(0, tk.END)
entry_widget.insert(0, directory) 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: if entry_widget == self.main_frame.svn_path_entry:
self.update_svn_status_indicator(directory) self.update_svn_status_indicator(directory)
else: else:
@ -291,17 +301,59 @@ class GitSvnSyncApp:
def update_svn_status_indicator(self, svn_path): 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 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") git_dir_path = os.path.join(svn_path, ".git")
is_prepared = os.path.exists(git_dir_path) 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: else:
self.logger.debug(f"SVN path '{svn_path}' is invalid or empty. Status set to unprepared.") 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'): 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 --- # --- Core Functionality Methods ---
@ -344,16 +396,20 @@ class GitSvnSyncApp:
svn_path = self._get_and_validate_svn_path("Prepare SVN") svn_path = self._get_and_validate_svn_path("Prepare SVN")
if not svn_path: return if not svn_path: return
# Save settings *before* potentially modifying .gitignore via prepare command
if not self.save_profile_settings(): if not self.save_profile_settings():
self.logger.warning("Prepare SVN: Could not save profile settings before proceeding.") 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") git_dir_path = os.path.join(svn_path, ".git")
if os.path.exists(git_dir_path): if os.path.exists(git_dir_path):
self.logger.info(f"Repository at '{svn_path}' is already prepared.") 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.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) self.update_svn_status_indicator(svn_path) # Ensure GUI is consistent
return return
# Execute Preparation
self.logger.info(f"Executing git preparation steps for: {svn_path}") self.logger.info(f"Executing git preparation steps for: {svn_path}")
try: try:
self.git_commands.prepare_svn_for_git(svn_path) self.git_commands.prepare_svn_for_git(svn_path)
@ -363,7 +419,7 @@ class GitSvnSyncApp:
except (GitCommandError, ValueError) as e: except (GitCommandError, ValueError) as e:
self.logger.error(f"Error preparing SVN repository: {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.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: except Exception as e:
self.logger.exception(f"Unexpected error during SVN preparation: {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}") 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.") self.main_frame.show_error("Error", "Cannot create bundle without a selected profile.")
return return
# Validate paths and names
svn_path = self._get_and_validate_svn_path("Create Bundle") svn_path = self._get_and_validate_svn_path("Create Bundle")
if not svn_path: return if not svn_path: return
usb_path = self._get_and_validate_usb_path("Create Bundle") 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.logger.error("Create Bundle: Bundle name cannot be empty.")
self.main_frame.show_error("Input Error", "Please enter a name for the bundle file.") self.main_frame.show_error("Input Error", "Please enter a name for the bundle file.")
return return
# Ensure .bundle extension
if not bundle_name.lower().endswith(".bundle"): if not bundle_name.lower().endswith(".bundle"):
self.logger.warning(f"Bundle name '{bundle_name}' does not end with '.bundle'. Adding extension.") self.logger.warning(f"Bundle name '{bundle_name}' does not end with '.bundle'. Adding extension.")
bundle_name += ".bundle" bundle_name += ".bundle"
@ -398,15 +456,17 @@ class GitSvnSyncApp:
bundle_full_path = os.path.join(usb_path, bundle_name) bundle_full_path = os.path.join(usb_path, bundle_name)
self.logger.debug(f"Target bundle file path: {bundle_full_path}") self.logger.debug(f"Target bundle file path: {bundle_full_path}")
# Save settings before proceeding
if not self.save_profile_settings(): if not self.save_profile_settings():
self.logger.warning("Create Bundle: Could not save profile settings before proceeding.") self.logger.warning("Create Bundle: Could not save profile settings before proceeding.")
# Decide whether to stop
# --- Backup --- # --- Backup ---
if self.main_frame.autobackup_var.get(): if self.main_frame.autobackup_var.get():
self.logger.info("Autobackup enabled. Starting backup...") self.logger.info("Autobackup enabled. Starting backup...")
if not self.create_backup(svn_path, profile): # Pass profile name for exclusions if not self.create_backup(svn_path, profile): # Pass profile name for exclusions
self.logger.error("Bundle creation aborted due to backup failure.") self.logger.error("Bundle creation aborted due to backup failure.")
return return # Stop the process if backup fails
# --- Autocommit --- # --- Autocommit ---
if self.main_frame.autocommit_var.get(): if self.main_frame.autocommit_var.get():
@ -415,20 +475,21 @@ class GitSvnSyncApp:
if self.git_commands.git_status_has_changes(svn_path): if self.git_commands.git_status_has_changes(svn_path):
self.logger.info("Changes detected, performing autocommit...") self.logger.info("Changes detected, performing autocommit...")
custom_message = self.main_frame.commit_message_var.get().strip() 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}'") 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) commit_made = self.git_commands.git_commit(svn_path, message=commit_message_to_use)
if commit_made: if commit_made:
self.logger.info("Autocommit successful.") self.logger.info("Autocommit successful.")
else: # else: 'nothing to commit' logged by git_commit method
self.logger.warning("Status reported changes, but autocommit resulted in 'nothing to commit'.")
else: else:
self.logger.info("No changes detected, skipping autocommit.") self.logger.info("No changes detected, skipping autocommit.")
except (GitCommandError, ValueError) as e: except (GitCommandError, ValueError) as e:
self.logger.error(f"Error during autocommit process: {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}") 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: except Exception as e:
self.logger.exception(f"Unexpected error during autocommit: {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}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during autocommit:\n{e}")
@ -437,22 +498,28 @@ class GitSvnSyncApp:
# --- Create Bundle --- # --- Create Bundle ---
self.logger.info(f"Creating Git bundle at: {bundle_full_path}") self.logger.info(f"Creating Git bundle at: {bundle_full_path}")
try: try:
# Call the decoupled GitCommands method
self.git_commands.create_git_bundle(svn_path, bundle_full_path) 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.logger.info("Git bundle created successfully.")
self.main_frame.show_info("Success", f"Git bundle created successfully:\n{bundle_full_path}") self.main_frame.show_info("Success", f"Git bundle created successfully:\n{bundle_full_path}")
else: 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.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.") 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): if os.path.exists(bundle_full_path):
try: os.remove(bundle_full_path) try: os.remove(bundle_full_path)
except OSError: pass # Ignore error removing empty file except OSError: pass # Ignore error removing empty file
except (GitCommandError, ValueError) as e: except (GitCommandError, ValueError) as e:
# Handle errors from git_commands
self.logger.error(f"Error creating Git bundle: {e}") 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}") self.main_frame.show_error("Bundle Creation Error", f"Failed to create Git bundle:\n{e}")
except Exception as e: except Exception as e:
# Catch unexpected errors
self.logger.exception(f"Unexpected error during bundle creation: {e}") 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}") 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.") self.main_frame.show_error("Error", "Cannot fetch from bundle without a selected profile.")
return return
# Validate paths and bundle name
svn_path = self._get_and_validate_svn_path("Fetch Bundle") svn_path = self._get_and_validate_svn_path("Fetch Bundle")
if not svn_path: return if not svn_path: return
usb_path = self._get_and_validate_usb_path("Fetch Bundle") 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) bundle_full_path = os.path.join(usb_path, bundle_updated_name)
self.logger.debug(f"Source bundle file path: {bundle_full_path}") 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.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}") self.main_frame.show_error("File Not Found", f"The specified bundle file does not exist:\n{bundle_full_path}")
return return
# Save settings before proceeding
if not self.save_profile_settings(): if not self.save_profile_settings():
self.logger.warning("Fetch Bundle: Could not save profile settings before proceeding.") self.logger.warning("Fetch Bundle: Could not save profile settings before proceeding.")
# Decide whether to stop
# --- Backup --- # --- Backup ---
if self.main_frame.autobackup_var.get(): if self.main_frame.autobackup_var.get():
self.logger.info("Autobackup enabled. Starting backup...") self.logger.info("Autobackup enabled. Starting backup...")
if not self.create_backup(svn_path, profile): # Pass profile name if not self.create_backup(svn_path, profile): # Pass profile name
self.logger.error("Fetch/merge aborted due to backup failure.") self.logger.error("Fetch/merge aborted due to backup failure.")
return return # Stop the process
# --- Fetch and Merge --- # --- Fetch and Merge ---
self.logger.info(f"Fetching/merging into '{svn_path}' from bundle: {bundle_full_path}") self.logger.info(f"Fetching/merging into '{svn_path}' from bundle: {bundle_full_path}")
try: try:
# Call the decoupled command
self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) 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.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: except GitCommandError as e:
# Handle specific errors from fetch/merge process
self.logger.error(f"Error fetching/merging from Git bundle: {e}") 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(): if "merge conflict" in str(e).lower():
self.main_frame.show_error( self.main_frame.show_error(
"Merge Conflict", "Merge Conflict",
f"Merge conflict occurred while applying changes from the bundle.\n\n" 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"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"After resolving, run 'git add .' and 'git commit' in that directory.\n\n"
f"Original Error: {e}" f"Original Error details in log."
) )
else: 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}") self.main_frame.show_error("Fetch/Merge Error", f"Failed to fetch or merge from bundle:\n{e}")
except ValueError as e: except ValueError as e:
# Handle validation errors (e.g., invalid path)
self.logger.error(f"Validation error during fetch/merge: {e}") 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}") self.main_frame.show_error("Input Error", f"Invalid input during fetch/merge:\n{e}")
except Exception as e: except Exception as e:
# Catch unexpected errors
self.logger.exception(f"Unexpected error during fetch/merge: {e}") 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}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during fetch/merge:\n{e}")
def _parse_exclusions(self, profile_name): 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="") exclude_str = self.config_manager.get_profile_option(profile_name, "backup_exclude_extensions", fallback="")
excluded_extensions = set() excluded_extensions = set()
if exclude_str: if exclude_str:
@ -534,9 +624,11 @@ class GitSvnSyncApp:
if not clean_ext.startswith('.'): if not clean_ext.startswith('.'):
clean_ext = '.' + clean_ext clean_ext = '.' + clean_ext
excluded_extensions.add(clean_ext) excluded_extensions.add(clean_ext)
# Always exclude these directories by default
excluded_dirs = {".git", ".svn"} # Define standard directories to always exclude
self.logger.debug(f"Parsed exclusions - Extensions: {excluded_extensions}, Dirs: {excluded_dirs}") 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 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}'") 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() backup_base_dir = self.main_frame.backup_dir_var.get().strip()
if not backup_base_dir: if not backup_base_dir:
self.logger.error("Backup failed: Backup directory not specified.") self.logger.error("Backup failed: Backup directory not specified.")
self.main_frame.show_error("Backup Error", "Backup directory is not specified.") self.main_frame.show_error("Backup Error", "Backup directory is not specified.")
return False return False
if not os.path.isdir(backup_base_dir): 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: 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: 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}") self.main_frame.show_error("Backup Error", f"Could not create backup directory:\n{backup_base_dir}\nError: {e}")
return False return False
# Parse exclusions # --- 2. Parse Exclusions ---
excluded_extensions, excluded_dirs = self._parse_exclusions(profile_name) 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") 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" 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_filename = f"{now}_backup_{safe_profile_name}.zip"
backup_full_path = os.path.join(backup_base_dir, backup_filename) backup_full_path = os.path.join(backup_base_dir, backup_filename)
self.logger.info(f"Target backup ZIP file: {backup_full_path}") self.logger.info(f"Target backup ZIP file: {backup_full_path}")
# Create ZIP Archive # --- 4. Create ZIP Archive ---
files_added_count = 0 files_added_count = 0
files_excluded_count = 0 files_excluded_count = 0
dirs_excluded_count = 0 dirs_excluded_count = 0
zip_file = None # Initialize to None
try: try:
with zipfile.ZipFile(backup_full_path, 'w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as zipf: # 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): for root, dirs, files in os.walk(source_repo_path, topdown=True):
# --- Directory Exclusion --- # --- Directory Exclusion ---
original_dirs = list(dirs) # Copy before modification original_dirs_in_level = list(dirs) # Copy before modifying dirs list in-place
dirs[:] = [d for d in dirs if d not in excluded_dirs] # Exclude based on base name (case-insensitive check for robustness)
# Log excluded directories dirs[:] = [d for d in dirs if d.lower() not in excluded_dirs_base]
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)}")
# 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)}")
# --- File Exclusion and Addition --- # --- File Exclusion and Addition ---
for filename in files: for filename in files:
# Get file extension (lowercase for comparison)
_, ext = os.path.splitext(filename) _, ext = os.path.splitext(filename)
file_ext_lower = ext.lower() file_ext_lower = ext.lower()
# Combine directory and extension check # Check if filename itself matches an excluded dir name (e.g. '.git' file)
parent_dir_name = os.path.basename(root) # or if the file extension is in the exclusion list
# Check if file itself should be excluded by dir name rule (e.g., file named '.git') if filename.lower() in excluded_dirs_base or file_ext_lower in excluded_extensions:
is_in_excluded_dir_structure = any(excluded_dir in os.path.normpath(root).split(os.sep) for excluded_dir in excluded_dirs)
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)}") self.logger.debug(f"Excluding file: {os.path.join(root, filename)}")
files_excluded_count += 1 files_excluded_count += 1
continue # Skip this file continue # Skip this file
# Construct full path and archive name (relative path inside ZIP)
file_full_path = os.path.join(root, filename) 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) archive_name = os.path.relpath(file_full_path, source_repo_path)
# Add file to zip
try: try:
zipf.write(file_full_path, arcname=archive_name) zip_file.write(file_full_path, arcname=archive_name)
files_added_count += 1 files_added_count += 1
if files_added_count % 200 == 0: # Log progress less frequently # 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...") self.logger.debug(f"Added {files_added_count} files to ZIP...")
except Exception as write_e: except Exception as write_e:
self.logger.error(f"Error writing file '{file_full_path}' to ZIP: {write_e}", exc_info=True) self.logger.error(f"Error writing file '{file_full_path}' to ZIP: {write_e}", exc_info=True)
# Decide whether to continue or abort # Option: Raise error to abort backup, or just log and continue
# raise write_e # Uncomment to abort on first write error
self.logger.info(f"Backup ZIP created: {backup_full_path}") # Log final counts after successful walk and write
self.logger.info(f"Files added: {files_added_count}, Files excluded: {files_excluded_count}, Dirs excluded: {dirs_excluded_count}") self.logger.info(f"Backup ZIP creation process finished for: {backup_full_path}")
return True 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: except OSError as e:
self.logger.error(f"OS error creating backup ZIP: {e}", exc_info=True) 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}") 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 return False
except zipfile.BadZipFile as e: except zipfile.BadZipFile as e:
self.logger.error(f"Error related to ZIP file format during backup: {e}", exc_info=True) 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}") 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 return False
except Exception as e: except Exception as e:
self.logger.exception(f"Unexpected error during ZIP backup creation: {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}") 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 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): def manual_backup(self):
"""Handles the 'Backup Now' button click.""" """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.") self.main_frame.show_error("Backup Error", "No profile selected to perform backup.")
return return
# Validate SVN Path
svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") 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...") self.logger.info("Saving current settings before manual backup...")
if not self.save_profile_settings(): if not self.save_profile_settings():
self.logger.error("Manual Backup: Could not save profile settings. Backup may use outdated 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?"): 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.") self.logger.warning("Manual backup aborted by user due to save failure.")
return return
# Call the create_backup method (which now creates ZIP)
self.logger.info(f"Starting manual backup for profile '{profile}'...") self.logger.info(f"Starting manual backup for profile '{profile}'...")
# Call the ZIP backup method success = self.create_backup(svn_path, profile) # Pass svn path and profile name
success = self.create_backup(svn_path, profile)
# Show result message
if success: if success:
self.main_frame.show_info("Backup Complete", f"Manual ZIP backup for profile '{profile}' completed successfully.") self.main_frame.show_info("Backup Complete", f"Manual ZIP backup for profile '{profile}' completed successfully.")
else: else:
self.logger.error(f"Manual backup failed for profile '{profile}'.") # Error message should have been shown by create_backup
# Error message already 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 --- # --- GUI State Utilities ---
def _clear_and_disable_fields(self): def _clear_and_disable_fields(self):
"""Clears repository config fields and disables action buttons.""" """Clears repository config fields and disables action buttons."""
if hasattr(self, 'main_frame'): if hasattr(self, 'main_frame'):
# Clear repository fields
self.main_frame.svn_path_entry.delete(0, tk.END) self.main_frame.svn_path_entry.delete(0, tk.END)
self.main_frame.usb_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_name_entry.delete(0, tk.END)
self.main_frame.bundle_updated_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.autocommit_var.set(False)
self.main_frame.commit_message_var.set("") # Clear commit message self.main_frame.commit_message_var.set("") # Clear commit message
# Don't clear backup fields, keep defaults? Or load default profile settings? # Optionally clear backup fields or load defaults? For now, leave as they are.
# Reset SVN indicator # self.main_frame.backup_exclude_extensions_var.set(".log,.tmp") # Reset exclude?
self.update_svn_status_indicator("")
# Disable action buttons # Reset SVN indicator and related buttons (Prepare, Edit Gitignore)
self._disable_function_buttons() 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.") 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'): if hasattr(self, 'main_frame'):
buttons = [ buttons = [
getattr(self.main_frame, 'prepare_svn_button', None),
getattr(self.main_frame, 'create_bundle_button', None), getattr(self.main_frame, 'create_bundle_button', None),
getattr(self.main_frame, 'fetch_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: for button in buttons:
if button: button.config(state=tk.DISABLED) 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): 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'): 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 general_state = tk.NORMAL # Assume enabled if profile is loaded
buttons_to_enable = [ buttons_to_enable = [
getattr(self.main_frame, 'create_bundle_button', None), getattr(self.main_frame, 'create_bundle_button', None),
@ -716,36 +860,48 @@ class GitSvnSyncApp:
for button in buttons_to_enable: for button in buttons_to_enable:
if button: button.config(state=general_state) 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.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): def show_fatal_error(self, message):
"""Shows a fatal error message before the app potentially exits.""" """Shows a fatal error message before the app potentially exits."""
try: # Try to show graphical error 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 except tk.TclError: # Fallback if GUI is not ready
print(f"FATAL ERROR: {message}") print(f"FATAL ERROR: {message}")
except Exception as e:
print(f"FATAL ERROR (and error showing message box: {e}): {message}")
# --- Application Entry Point --- # --- Application Entry Point ---
def main(): def main():
"""Main function to create the Tkinter root window and run the application.""" """Main function to create the Tkinter root window and run the application."""
root = tk.Tk() 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: try:
app = GitSvnSyncApp(root) 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 if hasattr(app, 'main_frame'): # Check if GUI was likely initialized
root.mainloop() root.mainloop()
else: else:
print("Application initialization failed, exiting.") print("Application initialization failed before GUI setup, exiting.")
except Exception as e: 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.") logging.exception("Fatal error during application startup or main loop.")
# Try showing message box, fallback to print # Try showing message box, fallback to print
try: try:
messagebox.showerror("Fatal Error", f"Application failed unexpectedly:\n{e}") # Check if root window exists before showing message box relative to it
except Exception: parent_window = root if root and root.winfo_exists() else None
print(f"FATAL ERROR: Application failed unexpectedly:\n{e}") 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__": if __name__ == "__main__":
main() main()

420
gui.py
View File

@ -2,7 +2,7 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox, simpledialog from tkinter import ttk, scrolledtext, filedialog, messagebox, simpledialog
import logging import logging
import os # Keep import import os # Import os for path operations
# Import constant from the central location # Import constant from the central location
from config_manager import DEFAULT_BACKUP_DIR from config_manager import DEFAULT_BACKUP_DIR
@ -22,16 +22,18 @@ class Tooltip:
self.hidetip() # Hide any existing tooltip first self.hidetip() # Hide any existing tooltip first
if not self.widget.winfo_exists(): return # Avoid error if widget destroyed if not self.widget.winfo_exists(): return # Avoid error if widget destroyed
try: try:
x, y, _, _ = self.widget.bbox("insert") # Get widget location # Get widget position relative to screen
x += self.widget.winfo_rootx() + 25 # Position tooltip slightly below and right 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 y += self.widget.winfo_rooty() + 25
except tk.TclError: # Handle cases where bbox might fail (e.g., widget not visible) 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 x = self.widget.winfo_rootx() + self.widget.winfo_width() // 2
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5 y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
self.tooltip_window = tw = tk.Toplevel(self.widget) # Create new top-level window 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_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, label = tk.Label(tw, text=self.text, justify=tk.LEFT,
background="#ffffe0", relief=tk.SOLID, borderwidth=1, # Light yellow background background="#ffffe0", relief=tk.SOLID, borderwidth=1, # Light yellow background
font=("tahoma", "8", "normal")) font=("tahoma", "8", "normal"))
@ -42,10 +44,209 @@ class Tooltip:
tw = self.tooltip_window tw = self.tooltip_window
self.tooltip_window = None self.tooltip_window = None
if tw: if tw:
try:
if tw.winfo_exists():
tw.destroy() tw.destroy()
except tk.TclError: # Handle cases where window might already be destroyed
pass
# --- End Tooltip Class --- # --- 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): class MainFrame(ttk.Frame):
""" """
The main frame containing all GUI elements for the Git SVN Sync Tool. 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, def __init__(self, master, load_profile_settings_cb, browse_folder_cb,
update_svn_status_cb, prepare_svn_for_git_cb, create_git_bundle_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, 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. Initializes the MainFrame.
Args: Args:
master (tk.Tk or ttk.Frame): The parent widget. master (tk.Tk or ttk.Frame): The parent widget.
load_profile_settings_cb (callable): Called when profile selection changes. Signature: func(profile_name) load_profile_settings_cb (callable): Called when profile selection changes.
browse_folder_cb (callable): Called by Browse buttons. Signature: func(entry_widget_to_update) browse_folder_cb (callable): Called by Browse buttons.
update_svn_status_cb (callable): Called when SVN path might change. Signature: func(svn_path) update_svn_status_cb (callable): Called when SVN path might change.
prepare_svn_for_git_cb (callable): Called by 'Prepare SVN' button. Signature: func() prepare_svn_for_git_cb (callable): Called by 'Prepare SVN' button.
create_git_bundle_cb (callable): Called by 'Create Bundle' button. Signature: func() create_git_bundle_cb (callable): Called by 'Create Bundle' button.
fetch_from_git_bundle_cb (callable): Called by 'Fetch Bundle' button. Signature: func() fetch_from_git_bundle_cb (callable): Called by 'Fetch Bundle' button.
config_manager_instance (ConfigManager): Instance to access config data if needed. config_manager_instance (ConfigManager): Instance to access config data.
profile_sections_list (list): Initial list of profile names for the dropdown. profile_sections_list (list): Initial list of profile names.
add_profile_cb (callable): Called by 'Add Profile' button. Signature: func() add_profile_cb (callable): Called by 'Add Profile' button.
remove_profile_cb (callable): Called by 'Remove Profile' button. Signature: func() remove_profile_cb (callable): Called by 'Remove Profile' button.
manual_backup_cb (callable): Called by 'Backup Now' button. Signature: func() manual_backup_cb (callable): Called by 'Backup Now' button.
open_gitignore_editor_cb (callable): Called by 'Edit .gitignore' button.
""" """
super().__init__(master) super().__init__(master)
self.master = master self.master = master
@ -89,7 +292,8 @@ class MainFrame(ttk.Frame):
self.fetch_from_git_bundle_callback = fetch_from_git_bundle_cb self.fetch_from_git_bundle_callback = fetch_from_git_bundle_cb
self.add_profile_callback = add_profile_cb self.add_profile_callback = add_profile_cb
self.remove_profile_callback = remove_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 # Store config manager and initial profiles if needed locally
self.config_manager = config_manager_instance self.config_manager = config_manager_instance
@ -97,7 +301,7 @@ class MainFrame(ttk.Frame):
# Style configuration # Style configuration
self.style = ttk.Style() self.style = ttk.Style()
self.style.theme_use('clam') self.style.theme_use('clam') # Example theme
# Pack the main frame # Pack the main frame
self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=10) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=10)
@ -112,7 +316,7 @@ class MainFrame(ttk.Frame):
# --- Widget Creation --- # --- Widget Creation ---
self._create_profile_frame() self._create_profile_frame()
self._create_repo_frame() self._create_repo_frame() # Modified
self._create_backup_frame() self._create_backup_frame()
self._create_function_frame() self._create_function_frame()
self._create_log_area() self._create_log_area()
@ -122,6 +326,7 @@ class MainFrame(ttk.Frame):
self.toggle_backup_dir() self.toggle_backup_dir()
# Initial status update is handled by the controller after loading profile # Initial status update is handled by the controller after loading profile
def _create_profile_frame(self): def _create_profile_frame(self):
"""Creates the frame for profile selection and management.""" """Creates the frame for profile selection and management."""
self.profile_frame = ttk.LabelFrame(self, text="Profile Configuration", padding=(10, 5)) 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_dropdown = ttk.Combobox(
self.profile_frame, self.profile_frame,
textvariable=self.profile_var, textvariable=self.profile_var,
state="readonly", state="readonly", # Prevent typing custom values
width=35, width=35, # Adjust width as needed
values=self.initial_profile_sections 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("<<ComboboxSelected>>", self.profile_dropdown.bind("<<ComboboxSelected>>",
lambda event: self.load_profile_settings_callback(self.profile_var.get())) lambda event: self.load_profile_settings_callback(self.profile_var.get()))
# Also trace the variable for programmatic changes
self.profile_var.trace_add("write", self.profile_var.trace_add("write",
lambda *args: self.load_profile_settings_callback(self.profile_var.get())) 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 = 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.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 = 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) 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) 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.""" """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 = ttk.LabelFrame(self, text="Repository Configuration", padding=(10, 5))
self.repo_frame.pack(pady=5, fill="x") self.repo_frame.pack(pady=5, fill="x")
col_entry_span = 2 # Span for entry widgets if browse/indicator are present # Define column indices for clarity and easier adjustment
col_browse = col_entry_span # Column index for browse buttons col_label = 0
col_indicator = col_browse + 1 # Column index for indicator 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 # 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 = 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("<FocusOut>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get())) self.svn_path_entry.bind("<FocusOut>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
self.svn_path_entry.bind("<Return>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get())) self.svn_path_entry.bind("<Return>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
self.svn_path_browse_button = ttk.Button( self.svn_path_browse_button = ttk.Button(
self.repo_frame, text="Browse...", width=9, self.repo_frame, text="Browse...", width=9,
command=lambda: self.browse_folder_callback(self.svn_path_entry) 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 = 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.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)") self.create_tooltip(self.svn_status_indicator, "Indicates if '.git' folder exists (Green=Yes, Red=No)")
# Row 1: USB/Bundle Target Path # 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 = 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.usb_path_browse_button = ttk.Button(
self.repo_frame, text="Browse...", width=9, self.repo_frame, text="Browse...", width=9,
command=lambda: self.browse_folder_callback(self.usb_path_entry) 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 # 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 = 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 # 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 = 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 # Row 4: Commit Message + Edit Button
ttk.Label(self.repo_frame, text="Commit Message:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=3) 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.commit_message_entry = ttk.Entry(
self.repo_frame, self.repo_frame,
textvariable=self.commit_message_var, # Use Tkinter variable textvariable=self.commit_message_var,
width=60 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.") 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 # Row 5: Autocommit Checkbox
self.autocommit_checkbox = ttk.Checkbutton( self.autocommit_checkbox = ttk.Checkbutton(
self.repo_frame, self.repo_frame,
text="Autocommit changes before 'Create Bundle'", text="Autocommit changes before 'Create Bundle'",
variable=self.autocommit_var 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 # 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))
# Configure column weights for horizontal resizing
self.repo_frame.columnconfigure(1, weight=1) # Allow entry fields (column 1) to expand
def _create_backup_frame(self): def _create_backup_frame(self):
"""Creates the frame for backup configuration including exclusions.""" """Creates the frame for backup configuration including exclusions."""
self.backup_frame = ttk.LabelFrame(self, text="Backup Configuration (ZIP)", padding=(10, 5)) self.backup_frame = ttk.LabelFrame(self, text="Backup Configuration (ZIP)", padding=(10, 5))
self.backup_frame.pack(pady=5, fill="x") self.backup_frame.pack(pady=5, fill="x")
col_entry_span = 1 # Default span for entry # Define columns
col_browse = 2 # Column for browse button 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 # Row 0: Autobackup Checkbox
self.autobackup_checkbox = ttk.Checkbutton( self.autobackup_checkbox = ttk.Checkbutton(
@ -230,41 +467,38 @@ class MainFrame(ttk.Frame):
variable=self.autobackup_var, variable=self.autobackup_var,
command=self.toggle_backup_dir 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 # Row 1: Backup Directory
self.backup_dir_label = ttk.Label(self.backup_frame, text="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_dir_entry = ttk.Entry(
self.backup_frame, self.backup_frame,
textvariable=self.backup_dir_var, textvariable=self.backup_dir_var,
width=60, width=60,
state=tk.DISABLED 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_dir_button = ttk.Button(
self.backup_frame, self.backup_frame,
text="Browse...", width=9, text="Browse...", width=9,
command=self.browse_backup_dir, command=self.browse_backup_dir,
state=tk.DISABLED 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 # Row 2: Exclude Extensions
self.backup_exclude_label = ttk.Label(self.backup_frame, text="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_exclude_entry = ttk.Entry(
self.backup_frame, self.backup_frame,
textvariable=self.backup_exclude_extensions_var, # Use Tkinter variable textvariable=self.backup_exclude_extensions_var, # Use Tkinter variable
width=60 width=60
) )
# Span across entry and browse columns # Span across entry and button columns
self.backup_exclude_entry.grid(row=2, column=1, columnspan=col_entry_span + (col_browse - 1), sticky=tk.EW, padx=5, pady=5) 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)") 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): def _create_function_frame(self):
"""Creates the frame holding the main action buttons.""" """Creates the frame holding the main action buttons."""
@ -310,16 +544,17 @@ class MainFrame(ttk.Frame):
def _create_log_area(self): def _create_log_area(self):
"""Creates the scrolled text area for logging output.""" """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)) 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( self.log_text = scrolledtext.ScrolledText(
log_frame, log_frame,
height=12, height=12, # Adjust height as desired
width=100, width=100, # Adjust width as desired
font=("Consolas", 9), font=("Consolas", 9), # Use a monospaced font like Consolas or Courier New
wrap=tk.WORD, wrap=tk.WORD, # Wrap lines at word boundaries
state=tk.DISABLED state=tk.DISABLED # Start in read-only state
) )
self.log_text.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) 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: if DEFAULT_PROFILE in self.initial_profile_sections:
self.profile_var.set(DEFAULT_PROFILE) 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]) self.profile_var.set(self.initial_profile_sections[0])
# else: variable remains empty # else: No profiles exist, variable remains empty
# --- GUI Update Methods --- # --- GUI Update Methods ---
@ -350,24 +586,29 @@ class MainFrame(ttk.Frame):
if hasattr(self, 'backup_dir_button'): if hasattr(self, 'backup_dir_button'):
self.backup_dir_button.config(state=new_state) self.backup_dir_button.config(state=new_state)
# Exclude entry state is independent of autobackup checkbox # 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): def browse_backup_dir(self):
"""Opens a directory selection dialog for the backup directory entry.""" """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( dirname = filedialog.askdirectory(
initialdir=initial_dir, initialdir=initial_dir,
title="Select Backup Directory", 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) self.backup_dir_var.set(dirname)
def update_svn_indicator(self, is_prepared): 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: if is_prepared:
indicator_color = self.GREEN indicator_color = self.GREEN
prepare_button_state = tk.DISABLED prepare_button_state = tk.DISABLED
@ -377,34 +618,47 @@ class MainFrame(ttk.Frame):
prepare_button_state = tk.NORMAL prepare_button_state = tk.NORMAL
tooltip_text = "Repository not prepared ('.git' not found)" tooltip_text = "Repository not prepared ('.git' not found)"
# Update indicator color
if hasattr(self, 'svn_status_indicator'): if hasattr(self, 'svn_status_indicator'):
self.svn_status_indicator.config(background=indicator_color) self.svn_status_indicator.config(background=indicator_color)
self.update_tooltip(self.svn_status_indicator, tooltip_text) self.update_tooltip(self.svn_status_indicator, tooltip_text)
# Update prepare button state
if hasattr(self, 'prepare_svn_button'): if hasattr(self, 'prepare_svn_button'):
self.prepare_svn_button.config(state=prepare_button_state) self.prepare_svn_button.config(state=prepare_button_state)
def update_profile_dropdown(self, sections): 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() current_profile = self.profile_var.get()
self.profile_dropdown['values'] = sections self.profile_dropdown['values'] = sections
# Try to maintain selection
# Maintain selection if possible, otherwise select default or first, or clear
if sections: if sections:
if current_profile in 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: elif "default" in sections:
self.profile_var.set("default") self.profile_var.set("default") # Select default if available
else: else:
self.profile_var.set(sections[0]) self.profile_var.set(sections[0]) # Select the first available
else: else:
self.profile_var.set("") self.profile_var.set("") # No profiles left, clear selection
# self.profile_dropdown.event_generate("<<ComboboxSelected>>") # Not always needed # Optionally trigger the callback if needed after programmatic change
# self.load_profile_settings_callback(self.profile_var.get())
# --- Dialog Wrappers --- # --- Dialog Wrappers ---
# These provide a consistent way to show standard dialogs via the GUI frame
def ask_new_profile_name(self): def ask_new_profile_name(self):
"""Asks the user for a new profile name using a simple dialog.""" """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) return simpledialog.askstring("Add Profile", "Enter new profile name:", parent=self.master)
def show_error(self, title, message): def show_error(self, title, message):
@ -425,14 +679,15 @@ class MainFrame(ttk.Frame):
# --- Tooltip Helper Methods --- # --- Tooltip Helper Methods ---
# Simple tooltip implementation for GUI elements
def create_tooltip(self, widget, text): def create_tooltip(self, widget, text):
"""Creates a tooltip for a given widget.""" """Creates a tooltip for a given widget."""
tooltip = Tooltip(widget, text) tooltip = Tooltip(widget, text)
# Use add='+' to avoid overwriting other bindings
widget.bind("<Enter>", lambda event, tt=tooltip: tt.showtip(), add='+') widget.bind("<Enter>", lambda event, tt=tooltip: tt.showtip(), add='+')
widget.bind("<Leave>", lambda event, tt=tooltip: tt.hidetip(), add='+') widget.bind("<Leave>", lambda event, tt=tooltip: tt.hidetip(), add='+')
widget.bind("<ButtonPress>", lambda event, tt=tooltip: tt.hidetip(), add='+') # Hide on click widget.bind("<ButtonPress>", 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): def update_tooltip(self, widget, text):
@ -442,8 +697,3 @@ class MainFrame(ttk.Frame):
widget.unbind("<Leave>") widget.unbind("<Leave>")
widget.unbind("<ButtonPress>") widget.unbind("<ButtonPress>")
self.create_tooltip(widget, text) 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)