Autocommit profile 'GitUtility'
This commit is contained in:
parent
27a48b6e2f
commit
9e6433bd52
380
GitTool.py
380
GitTool.py
@ -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
|
||||||
for root, dirs, files in os.walk(source_repo_path, topdown=True):
|
zip_file = zipfile.ZipFile(backup_full_path, 'w', compression=zipfile.ZIP_DEFLATED, allowZip64=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)}")
|
|
||||||
|
|
||||||
|
# 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 ---
|
# Log excluded directories for this level
|
||||||
for filename in files:
|
excluded_now = set(original_dirs_in_level) - set(dirs)
|
||||||
_, ext = os.path.splitext(filename)
|
if excluded_now:
|
||||||
file_ext_lower = ext.lower()
|
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
|
# --- File Exclusion and Addition ---
|
||||||
parent_dir_name = os.path.basename(root)
|
for filename in files:
|
||||||
# Check if file itself should be excluded by dir name rule (e.g., file named '.git')
|
# Get file extension (lowercase for comparison)
|
||||||
is_in_excluded_dir_structure = any(excluded_dir in os.path.normpath(root).split(os.sep) for excluded_dir in excluded_dirs)
|
_, 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:
|
# Construct full path and archive name (relative path inside ZIP)
|
||||||
self.logger.debug(f"Excluding file: {os.path.join(root, filename)}")
|
file_full_path = os.path.join(root, filename)
|
||||||
files_excluded_count += 1
|
# Arcname ensures files are stored with relative paths inside zip
|
||||||
continue # Skip this file
|
archive_name = os.path.relpath(file_full_path, source_repo_path)
|
||||||
|
|
||||||
file_full_path = os.path.join(root, filename)
|
# Add file to zip
|
||||||
archive_name = os.path.relpath(file_full_path, source_repo_path)
|
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:
|
# Log final counts after successful walk and write
|
||||||
zipf.write(file_full_path, arcname=archive_name)
|
self.logger.info(f"Backup ZIP creation process finished for: {backup_full_path}")
|
||||||
files_added_count += 1
|
self.logger.info(f"Summary - Files added: {files_added_count}, Files excluded: {files_excluded_count}, Dirs excluded: {dirs_excluded_count}")
|
||||||
if files_added_count % 200 == 0: # Log progress less frequently
|
return True # Backup succeeded
|
||||||
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
|
|
||||||
|
|
||||||
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()
|
||||||
422
gui.py
422
gui.py
@ -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:
|
||||||
tw.destroy()
|
try:
|
||||||
|
if tw.winfo_exists():
|
||||||
|
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)
|
|
||||||
Loading…
Reference in New Issue
Block a user