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 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()

424
gui.py
View File

@ -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("<<ComboboxSelected>>",
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("<FocusOut>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
self.svn_path_entry.bind("<Return>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
self.svn_path_browse_button = ttk.Button(
self.repo_frame, text="Browse...", width=9,
command=lambda: self.browse_folder_callback(self.svn_path_entry)
)
self.svn_path_browse_button.grid(row=0, column=col_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("<<ComboboxSelected>>") # 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("<Enter>", lambda event, tt=tooltip: tt.showtip(), add='+')
widget.bind("<Leave>", lambda event, tt=tooltip: tt.hidetip(), add='+')
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):
@ -441,9 +696,4 @@ class MainFrame(ttk.Frame):
widget.unbind("<Enter>")
widget.unbind("<Leave>")
widget.unbind("<ButtonPress>")
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)
self.create_tooltip(widget, text)