# --- FILE: gitsync_tool/gui/editors.py --- import tkinter as tk from tkinter import ttk, messagebox, scrolledtext import os from typing import Optional, Callable # Usa import assoluto per il log handler from gitsync_tool.logging_setup import log_handler class GitignoreEditorWindow(tk.Toplevel): """ Toplevel window for editing the .gitignore file. Provides basic text editing capabilities and save/cancel options. Calls a callback function upon successful save. """ def __init__( self, master: tk.Misc, gitignore_path: str, on_save_success_callback: Optional[Callable[[], None]] = None, ): """ Initialize the Gitignore Editor window. Args: master: The parent window. gitignore_path (str): The absolute path to the .gitignore file. on_save_success_callback (Optional[Callable]): A function to call after successfully saving. """ super().__init__(master) self.gitignore_path: str = gitignore_path self.original_content: str = "" self.on_save_success_callback: Optional[Callable[[], None]] = ( on_save_success_callback ) self.title(f"Edit {os.path.basename(gitignore_path)}") self.geometry("600x450") self.minsize(400, 300) # Make the window modal self.grab_set() self.transient(master) # Handle window close button (X) self.protocol("WM_DELETE_WINDOW", self._on_close) # --- Create Widgets --- # Main frame main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) main_frame.rowconfigure(0, weight=1) # Text editor area expands main_frame.columnconfigure(0, weight=1) # Text editor area expands # ScrolledText Editor self.text_editor = scrolledtext.ScrolledText( main_frame, wrap=tk.WORD, font=("Consolas", 10), # Monospaced font is good for code/config undo=True, # Enable basic undo/redo padx=5, pady=5, borderwidth=1, relief=tk.SUNKEN, ) self.text_editor.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) # Button Frame (aligned to the right) button_frame = ttk.Frame(main_frame) button_frame.grid(row=1, column=0, sticky="e") # Use sticky="e" self.save_button = ttk.Button( button_frame, text="Save and Close", command=self._save_and_close, # Consider adding tooltip if Tooltip class is imported/available ) self.save_button.pack(side=tk.RIGHT, padx=5) # Pack buttons from right to left self.cancel_button = ttk.Button( button_frame, text="Cancel", command=self._on_close, # Consider adding tooltip ) self.cancel_button.pack(side=tk.RIGHT) # Pack next to save button # Load initial content and center window self._load_file() self._center_window(master) # Wait for this window to be closed before returning control # This makes it behave like a modal dialog self.wait_window() def _center_window(self, parent: tk.Misc) -> None: """Centers the Toplevel window relative to its parent.""" func_name = "_center_window (GitignoreEditor)" try: self.update_idletasks() # Ensure window dimensions are calculated # Get parent geometry parent_x: int = parent.winfo_rootx() parent_y: int = parent.winfo_rooty() parent_w: int = parent.winfo_width() parent_h: int = parent.winfo_height() # Get self geometry win_w: int = self.winfo_width() win_h: int = self.winfo_height() # Calculate position pos_x: int = parent_x + (parent_w // 2) - (win_w // 2) pos_y: int = parent_y + (parent_h // 2) - (win_h // 2) # Keep window on screen (simple check) screen_w: int = self.winfo_screenwidth() screen_h: int = self.winfo_screenheight() pos_x = max(0, min(pos_x, screen_w - win_w)) pos_y = max(0, min(pos_y, screen_h - win_h)) # Set geometry self.geometry(f"+{int(pos_x)}+{int(pos_y)}") except Exception as e: log_handler.log_error( f"Could not center GitignoreEditorWindow: {e}", func_name=func_name ) def _load_file(self) -> None: """Loads the content of the .gitignore file into the text editor.""" func_name = "_load_file (GitignoreEditor)" log_handler.log_info( f"Loading gitignore file: {self.gitignore_path}", func_name=func_name ) content: str = "" try: # Check if the file exists before trying to read if os.path.exists(self.gitignore_path): # Open with UTF-8, replace errors if any decoding issues occur with open( self.gitignore_path, "r", encoding="utf-8", errors="replace" ) as f: content = f.read() log_handler.log_debug( f"Read {len(content)} chars from existing .gitignore.", func_name=func_name, ) else: # File doesn't exist, treat as empty content log_handler.log_info( f".gitignore does not exist at path: {self.gitignore_path}. Starting with empty editor.", func_name=func_name, ) content = ( "# Enter patterns for files/directories to ignore (one per line)\n" ) content += "# Lines starting with # are comments.\n" content += "# Example: build/, *.log, __pycache__/\n" # Store original content for change detection self.original_content = content # Populate the text editor widget self.text_editor.config(state=tk.NORMAL) # Ensure editable self.text_editor.delete("1.0", tk.END) # Clear existing content self.text_editor.insert(tk.END, content) # Insert loaded content self.text_editor.edit_reset() # Reset undo/redo stack self.text_editor.focus_set() # Set focus to the editor except IOError as e: log_handler.log_error( f"I/O error loading .gitignore file '{self.gitignore_path}': {e}", func_name=func_name, ) messagebox.showerror( "Load Error", f"Error reading .gitignore:\n{e}", parent=self ) # Disable editor if loading fails critically self.text_editor.config(state=tk.DISABLED) except Exception as e: log_handler.log_exception( f"Unexpected error loading .gitignore file '{self.gitignore_path}': {e}", func_name=func_name, ) messagebox.showerror( "Load Error", f"Unexpected error loading file:\n{e}", parent=self ) self.text_editor.config(state=tk.DISABLED) def _has_changes(self) -> bool: """Checks if the editor content differs from the originally loaded content.""" try: # Get current content, excluding the trailing newline Tk adds current_content: str = self.text_editor.get("1.0", "end-1c") # Compare with the stored original content return current_content != self.original_content except Exception as e: # Assume changes if error occurs during check log_handler.log_error( f"Error checking for changes in editor: {e}", func_name="_has_changes" ) return True def _save_file(self) -> bool: """Saves the current editor content to the .gitignore file.""" func_name = "_save_file (GitignoreEditor)" # Avoid saving if there are no actual changes if not self._has_changes(): log_handler.log_info( "No changes detected in .gitignore, skipping save.", func_name=func_name ) return True # Consider "no changes" as a successful save action # Get current content from the editor current_content: str = self.text_editor.get("1.0", "end-1c") log_handler.log_info( f"Saving changes to .gitignore file: {self.gitignore_path}", func_name=func_name, ) try: # Write content using UTF-8 and ensure consistent newline handling with open(self.gitignore_path, "w", encoding="utf-8", newline="\n") as f: f.write(current_content) log_handler.log_info(".gitignore saved successfully.", func_name=func_name) # Update original content state and editor's modified flag self.original_content = current_content self.text_editor.edit_modified(False) # Mark as not modified since save return True # Indicate success except IOError as e: log_handler.log_error( f"I/O error saving .gitignore file '{self.gitignore_path}': {e}", func_name=func_name, ) messagebox.showerror( "Save Error", f"Error writing .gitignore:\n{e}", parent=self ) return False # Indicate failure except Exception as e: log_handler.log_exception( f"Unexpected error saving .gitignore file '{self.gitignore_path}': {e}", func_name=func_name, ) messagebox.showerror( "Save Error", f"Unexpected error saving file:\n{e}", parent=self ) return False # Indicate failure def _save_and_close(self) -> None: """Saves the file and closes the window, calling callback on success.""" func_name = "_save_and_close (GitignoreEditor)" save_successful: bool = self._save_file() if save_successful: log_handler.log_debug( "Save successful, checking and calling callback if available.", func_name=func_name, ) # Call the success callback if provided and callable if self.on_save_success_callback and callable( self.on_save_success_callback ): try: log_handler.log_info( "Executing on_save_success_callback.", func_name=func_name ) self.on_save_success_callback() except Exception as cb_e: log_handler.log_exception( f"Error occurred in on_save_success_callback: {cb_e}", func_name=func_name, ) # Inform user about the callback error, but proceed with closing messagebox.showwarning( "Callback Error", "File saved successfully, but an error occurred during the post-save action.\nPlease check the application logs.", parent=self, ) # Close the editor window after successful save (and callback attempt) self.destroy() def _on_close(self) -> None: """Handles window close event (X button or Cancel button).""" func_name = "_on_close (GitignoreEditor)" # Check for unsaved changes before closing if self._has_changes(): # Ask user whether to save, discard, or cancel closing response = messagebox.askyesnocancel( "Unsaved Changes", "There are unsaved changes in the editor.\nDo you want to save them before closing?", parent=self, # Ensure message box is modal to this window ) if response is True: # User chose Yes: attempt save and close self._save_and_close() elif response is False: # User chose No: discard changes and close log_handler.log_warning( "Discarding unsaved .gitignore changes.", func_name=func_name ) self.destroy() # else (response is None): User chose Cancel - do nothing, keep editor open else: # No changes: simply close the window log_handler.log_debug( "No changes detected, closing editor.", func_name=func_name ) self.destroy() # --- END OF FILE gitsync_tool/gui/editors.py ---