From b5dbd759991bd5dbcc37e3f8c435b485eeb03508 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Fri, 18 Apr 2025 15:53:30 +0200 Subject: [PATCH] add diff_viewer into commit tab --- GitUtility.py | 1580 ++++++++++++++++++++++++++++++----------------- diff_viewer.py | 538 ++++++++++++++++ git_commands.py | 79 ++- gui.py | 130 ++-- 4 files changed, 1740 insertions(+), 587 deletions(-) create mode 100644 diff_viewer.py diff --git a/GitUtility.py b/GitUtility.py index 526d501..227c4eb 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -1,47 +1,46 @@ # GitUtility.py import os - -# Rimosso shutil se non usato altrove import datetime import tkinter as tk -from tkinter import messagebox, filedialog # Assicurati che filedialog sia importato +from tkinter import messagebox, filedialog import logging -import re -# Rimosso zipfile, threading, queue +import re # Importato per l'analisi dei tag in _generate_next_tag_suggestion # Import application modules try: from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR - - # ActionHandler e BackupHandler sono necessari from action_handler import ActionHandler from backup_handler import BackupHandler from git_commands import GitCommands, GitCommandError from logger_config import setup_logger - - # Import GUI classes (SENZA WaitWindow) + # Import GUI classes from gui import ( MainFrame, GitignoreEditorWindow, CreateTagDialog, CreateBranchDialog, - # Rimuovi WaitWindow se importata precedentemente ) + # Importa la nuova finestra di diff + from diff_viewer import DiffViewerWindow + except ImportError as e: + # Configurazione di logging minimale per errori critici all'avvio logging.basicConfig(level=logging.CRITICAL) logging.critical( f"Critical Error: Failed to import required application modules: {e}", exc_info=True, ) + # Stampa anche su console se il logging non funziona o non è visibile print(f"FATAL: Failed to import required application modules: {e}") + # Uscita pulita se i moduli essenziali mancano exit(1) class GitSvnSyncApp: """ - Main application class for the Git SVN Sync Tool. + Main application class for the Git Sync Tool. Coordinates the GUI, configuration, and delegates actions to ActionHandler. - (Versione Sincrona - Senza Threading/WaitWindow) + (Versione Sincrona) """ def __init__(self, master): @@ -52,25 +51,21 @@ class GitSvnSyncApp: master (tk.Tk): The main Tkinter root window. """ self.master = master - master.title("Git SVN Sync Tool") + master.title("Git Sync Tool (Bundle Manager)") # Titolo aggiornato master.protocol("WM_DELETE_WINDOW", self.on_closing) - # Basic logging setup first + # Basic logging setup first (verrà riconfigurato dopo la GUI) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) self.logger = logging.getLogger(self.__class__.__name__) - # Rimuovi queue - non serve più - # self.result_queue = queue.Queue() - # --- Initialize Core Components --- try: self.config_manager = ConfigManager(self.logger) self.git_commands = GitCommands(self.logger) - self.backup_handler = BackupHandler(self.logger) # Istanza necessaria - # Istanzia ActionHandler passando le dipendenze + self.backup_handler = BackupHandler(self.logger) self.action_handler = ActionHandler( self.logger, self.git_commands, self.backup_handler ) @@ -78,72 +73,101 @@ class GitSvnSyncApp: self.logger.critical( f"Failed to initialize core components: {e}", exc_info=True ) + # Usa show_fatal_error che gestisce la distruzione della finestra self.show_fatal_error( f"Initialization Error:\n{e}\n\nApplication cannot start." ) - if master and master.winfo_exists(): - master.destroy() + # Non ritornare subito, lascia che show_fatal_error gestisca la finestra return # --- Initialize GUI (MainFrame) --- try: - # Assicurati che le callback puntino ai metodi corretti (ora sincroni) self.main_frame = MainFrame( master, + # Callbacks gestione profilo e percorsi load_profile_settings_cb=self.load_profile_settings, browse_folder_cb=self.browse_folder, update_svn_status_cb=self.update_svn_status_indicator, - prepare_svn_for_git_cb=self.prepare_svn_for_git, # Chiama metodo sincrono - create_git_bundle_cb=self.create_git_bundle, # Chiama metodo sincrono - fetch_from_git_bundle_cb=self.fetch_from_git_bundle, # Chiama metodo sincrono - open_gitignore_editor_cb=self.open_gitignore_editor, - manual_backup_cb=self.manual_backup, # Chiama metodo sincrono - config_manager_instance=self.config_manager, - profile_sections_list=self.config_manager.get_profile_sections(), add_profile_cb=self.add_profile, remove_profile_cb=self.remove_profile, save_profile_cb=self.save_profile_settings, - commit_changes_cb=self.commit_changes, # Chiama metodo sincrono - refresh_tags_cb=self.refresh_tag_list, # Chiama metodo sincrono - create_tag_cb=self.create_tag, # Chiama metodo sincrono - checkout_tag_cb=self.checkout_tag, # Chiama metodo sincrono - refresh_branches_cb=self.refresh_branch_list, # Chiama metodo sincrono - checkout_branch_cb=self.checkout_branch, # Chiama metodo sincrono - create_branch_cb=self.create_branch, # Chiama metodo sincrono - refresh_history_cb=self.refresh_commit_history, # Chiama metodo sincrono + # Callbacks azioni principali + prepare_svn_for_git_cb=self.prepare_svn_for_git, + create_git_bundle_cb=self.create_git_bundle, + fetch_from_git_bundle_cb=self.fetch_from_git_bundle, + # Callbacks azioni specifiche + open_gitignore_editor_cb=self.open_gitignore_editor, + manual_backup_cb=self.manual_backup, + commit_changes_cb=self.commit_changes, + # Callbacks Tag + refresh_tags_cb=self.refresh_tag_list, + create_tag_cb=self.create_tag, + checkout_tag_cb=self.checkout_tag, + # Callbacks Branch + refresh_branches_cb=self.refresh_branch_list, + checkout_branch_cb=self.checkout_branch, + create_branch_cb=self.create_branch, + # Callbacks History & Changes + refresh_history_cb=self.refresh_commit_history, + refresh_changed_files_cb=self.refresh_changed_files_list, # Nuovo + open_diff_viewer_cb=self.open_diff_viewer, # Nuovo + # Passa istanza config manager e profili iniziali + config_manager_instance=self.config_manager, + profile_sections_list=self.config_manager.get_profile_sections(), ) - # --- GESTIONE TRACE INIZIALE (Come corretto precedentemente) --- + # --- Collega widget GUI specifici (che non passano dal costruttore di MainFrame) --- + if hasattr(self.main_frame, 'refresh_changes_button'): + self.main_frame.refresh_changes_button.config( + command=self.refresh_changed_files_list + ) + # Il doppio click sulla lista file è collegato internamente in MainFrame + # al callback 'open_diff_viewer_cb' passato sopra. + + # --- GESTIONE TRACE INIZIALE (Per caricamento profilo senza triggerare loop) --- + # Salva info trace scrittura sulla variabile del profilo trace_info = self.main_frame.profile_var.trace_info() write_trace_callback_info = None if trace_info: for info in trace_info: - if "write" in info[1]: - try: # trace_vinfo might fail if var destroyed + # trace_info può essere vuoto o non contenere 'write' + if info and len(info) > 1 and "write" in info[1]: + try: + # Ottiene info specifiche del callback ('write', nome_callback) write_trace_callback_info = ( self.main_frame.profile_var.trace_vinfo()[0] ) self.logger.debug( - f"Temporarily removing 'write' trace: {write_trace_callback_info}" + "Temporarily removing 'write' trace for initial load: " + f"{write_trace_callback_info}" ) + # Rimuove temporaneamente il trace per evitare chiamate multiple self.main_frame.profile_var.trace_vdelete( *write_trace_callback_info ) - except tk.TclError: - write_trace_callback_info = None - break + except (tk.TclError, IndexError) as trace_err: + self.logger.warning( + f"Could not get/remove profile var trace: {trace_err}" + ) + write_trace_callback_info = None # Non rimuovere/riaggiungere + break # Trovato e rimosso (o fallito), esci dal loop - #self._initialize_profile_selection() - self._perform_initial_load() # Carica dati iniziali ORA + # Esegui il caricamento iniziale del profilo ORA, senza il trace attivo + self._perform_initial_load() + # Riaggiungi il trace di scrittura se era stato rimosso con successo if write_trace_callback_info: - self.logger.debug(f"Re-adding 'write' trace.") - self.main_frame.profile_var.trace_add( - "write", - lambda n, i, m: self.load_profile_settings( - self.main_frame.profile_var.get() - ), - ) + self.logger.debug("Re-adding 'write' trace for profile variable.") + try: + self.main_frame.profile_var.trace_add( + "write", + # Usa una lambda per passare il valore corrente alla callback + lambda n, i, m: self.load_profile_settings( + self.main_frame.profile_var.get() + ), + ) + except tk.TclError as trace_err: + self.logger.error(f"Could not re-add profile var trace: {trace_err}") # --- FINE GESTIONE TRACE --- except Exception as e: @@ -153,112 +177,102 @@ class GitSvnSyncApp: self.show_fatal_error( f"GUI Initialization Error:\n{e}\n\nApplication cannot start." ) - if master and master.winfo_exists(): - master.destroy() return # --- Finalize Logger Setup --- + # Associa l'handler della GUI al logger dopo che la GUI è stata creata try: if hasattr(self.main_frame, "log_text"): + # Setup logger con il widget della GUI self.logger = setup_logger(self.main_frame.log_text) + # Propaga il logger aggiornato ai componenti che lo usano self.config_manager.logger = self.logger self.git_commands.logger = self.logger self.backup_handler.logger = self.logger self.action_handler.logger = self.logger self.logger.info("Logger setup complete with GUI handler.") else: - self.logger.error("GUI log text widget not found.") + # Questo non dovrebbe succedere se MainFrame è costruito correttamente + self.logger.error("GUI log text widget not found after GUI initialization.") except Exception as log_e: self.logger.error( f"Error setting up logger with GUI TextHandler: {log_e}", exc_info=True ) - self.main_frame.update_status_bar("Ready.") - self.logger.info("Git SVN Sync Tool initialization sequence complete.") + # --- Imposta stato iniziale Status Bar --- + self.main_frame.update_status_bar("Ready.") + # --- Fine Stato Iniziale --- + + self.logger.info("Git Sync Tool initialization sequence complete.") def _perform_initial_load(self): """Loads the initially selected profile and updates UI state.""" - # (Come definito precedentemente) self.logger.debug("Performing initial profile load and UI update.") initial_profile = self.main_frame.profile_var.get() if initial_profile: + # Chiama direttamente load_profile_settings ORA self.load_profile_settings(initial_profile) else: self.logger.warning("No initial profile set during initial load.") + # Pulisci e disabilita i campi se nessun profilo è selezionato self._clear_and_disable_fields() - #self.logger.info("Application started and initial state set.") - - if self.main_frame.status_bar_var.get() == "": # Imposta solo se vuota - self.main_frame.update_status_bar("Ready.") - - def _handle_gitignore_save(self): - """ - Callback function triggered after .gitignore is saved successfully. - Initiates the process to untrack newly ignored files. - """ - self.logger.info("Callback triggered: .gitignore saved. Checking for files to untrack automatically...") - # Need the svn_path again here - svn_path = self._get_and_validate_svn_path("Automatic Untracking") - if not svn_path: - self.logger.error("Cannot perform automatic untracking: Invalid SVN path after save.") - # Show error? This shouldn't happen if editor opened correctly. - return + # Imposta stato barra se nessun profilo + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("No profile selected.") - try: - # Call the ActionHandler method - untracked = self.action_handler.execute_untrack_files_from_gitignore(svn_path) - - if untracked: - self.main_frame.show_info( - "Automatic Untrack", - "Successfully untracked newly ignored files and created commit.\nCheck log for details." - ) - # UI might need refreshing after commit - handled after window closes in open_gitignore_editor - else: - # No files needed untracking, or commit failed (ActionHandler logs warnings) - self.logger.info("Automatic untracking check complete. No files untracked or no commit needed.") - - except (GitCommandError, ValueError) as e: - self.logger.error(f"Automatic untracking failed: {e}", exc_info=True) - self.main_frame.show_error("Untrack Error", f"Failed automatic untracking:\n{e}") - except Exception as e: - self.logger.exception(f"Unexpected error during automatic untracking: {e}") - self.main_frame.show_error("Untrack Error", f"Unexpected error during untracking:\n{e}") + # Non impostare "Ready." qui, load_profile_settings lo farà. def on_closing(self): """Handles the window close event.""" - # (Come definito precedentemente) + # Opzionale: aggiorna status bar alla chiusura + if hasattr(self, 'main_frame'): + try: + self.main_frame.update_status_bar("Exiting...") + except: pass # Ignora errori se la finestra sta già chiudendo self.logger.info("Application closing.") if self.master and self.master.winfo_exists(): self.master.destroy() # --- Profile Management Callbacks --- - # (load_profile_settings, save_profile_settings, add_profile, remove_profile) - # Nessuna modifica necessaria rispetto alla versione precedente (Sezione 2) - # Assicurarsi che carichino/salvino self.main_frame.backup_exclude_dirs_var.get() def load_profile_settings(self, profile_name): """Loads settings for the selected profile into the GUI.""" self.logger.info(f"Loading settings for profile: '{profile_name}'") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(f"Loading profile: {profile_name}...") + + # Validazione profilo if ( not profile_name or profile_name not in self.config_manager.get_profile_sections() ): self.logger.warning(f"Profile '{profile_name}' invalid or not found.") - self._clear_and_disable_fields() - if profile_name: + self._clear_and_disable_fields() # Pulisci e disabilita + if profile_name and hasattr(self, 'main_frame'): # Mostra errore solo se si cercava un profilo specifico self.main_frame.show_error( "Profile Error", f"Profile '{profile_name}' not found." ) - return + self.main_frame.update_status_bar(f"Error: Profile '{profile_name}' not found.") + elif hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("No profile selected.") + return # Esce se il profilo non è valido + + # Ottieni dati dal config manager cm = self.config_manager expected_keys = cm._get_expected_keys_with_defaults() settings = {} for key, default in expected_keys.items(): settings[key] = cm.get_profile_option(profile_name, key, fallback=default) + + # Applica impostazioni alla GUI if not hasattr(self, "main_frame") or not self.main_frame.winfo_exists(): + self.logger.error("Main frame not available during profile load.") + if hasattr(self, 'main_frame'): # Prova ad aggiornare status bar anche se frame parziale + self.main_frame.update_status_bar("Error: GUI not ready during load.") return + mf = self.main_frame + status_final = f"Profile '{profile_name}' loaded." # Default successo try: # Repo Tab mf.svn_path_entry.delete(0, tk.END) @@ -281,30 +295,43 @@ class GitSvnSyncApp: ) mf.backup_exclude_dirs_var.set( settings.get("backup_exclude_dirs", "") - ) # <<< Carica dirs + ) + # Aggiorna stato widget backup dir mf.toggle_backup_dir() # Commit Tab mf.autocommit_var.set( str(settings.get("autocommit", "False")).lower() == "true" ) + # Pulisci e riempi messaggio commit mf.clear_commit_message() if ( hasattr(mf, "commit_message_text") and mf.commit_message_text.winfo_exists() ): - if mf.commit_message_text.cget("state") == tk.DISABLED: + # Assicura che sia scrivibile prima di inserire + current_state = mf.commit_message_text.cget("state") + if current_state == tk.DISABLED: mf.commit_message_text.config(state=tk.NORMAL) mf.commit_message_text.insert("1.0", settings.get("commit_message", "")) + # Ripristina stato originale se necessario (verrà gestito da update_svn_status) + # mf.commit_message_text.config(state=current_state) + self.logger.info(f"Successfully loaded settings for '{profile_name}'.") - # Post-Load Updates + + # Post-Load Updates (Stato, Liste) svn_path_loaded = settings.get("svn_working_copy_path", "") - self.update_svn_status_indicator(svn_path_loaded) + # Aggiorna stato indicatori e pulsanti + self.update_svn_status_indicator(svn_path_loaded) # Questo aggiorna anche stato commit message + repo_is_ready = self._is_repo_ready(svn_path_loaded) if repo_is_ready: + # Aggiorna liste solo se il repository è pronto self.refresh_tag_list() self.refresh_branch_list() self.refresh_commit_history() + self.refresh_changed_files_list() # Aggiorna anche nuova lista else: + # Pulisci le liste se il repository non è pronto if hasattr(mf, "update_tag_list"): mf.update_tag_list([]) if hasattr(mf, "update_branch_list"): @@ -313,25 +340,48 @@ class GitSvnSyncApp: mf.update_history_display([]) if hasattr(mf, "update_history_branch_filter"): mf.update_history_branch_filter([]) + if hasattr(mf, "update_changed_files_list"): + mf.update_changed_files_list(["(Repository not ready)"]) # Pulisci nuova lista + status_final = f"Profile '{profile_name}' loaded (Repo not ready)." + + except Exception as e: self.logger.error( f"Error applying loaded settings for '{profile_name}': {e}", exc_info=True, ) - self.main_frame.show_error("Load Error", f"Failed to apply settings:\n{e}") + status_final = f"Error loading profile '{profile_name}'." + mf.show_error("Load Error", f"Failed to apply settings:\n{e}") + finally: + mf.update_status_bar(status_final) + def save_profile_settings(self): """Saves current GUI values to the selected profile.""" profile_name = self.main_frame.profile_var.get() if not profile_name: self.logger.warning("Save failed: No profile selected.") - return False + # Aggiorna status bar se main_frame esiste + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Save failed: No profile selected.") + return False # Indica fallimento + self.logger.info(f"Saving settings for profile: '{profile_name}'") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(f"Saving profile: {profile_name}...") + + # Verifica esistenza main_frame if not hasattr(self, "main_frame") or not self.main_frame.winfo_exists(): - return False + self.logger.error("Cannot save profile: Main frame not available.") + # Non possiamo aggiornare status bar qui + return False + mf = self.main_frame cm = self.config_manager + status_final = "Ready." # Default per finally + success = False try: + # Raccogli valori dalla GUI settings_to_save = { "svn_working_copy_path": mf.svn_path_entry.get(), "usb_drive_path": mf.usb_path_entry.get(), @@ -342,240 +392,296 @@ class GitSvnSyncApp: "autobackup": str(mf.autobackup_var.get()), "backup_dir": mf.backup_dir_var.get(), "backup_exclude_extensions": mf.backup_exclude_extensions_var.get(), - "backup_exclude_dirs": mf.backup_exclude_dirs_var.get(), # <<< Salva dirs + "backup_exclude_dirs": mf.backup_exclude_dirs_var.get(), } + # Salva ogni opzione tramite ConfigManager for key, value in settings_to_save.items(): cm.set_profile_option(profile_name, key, value) + + # Scrivi sul file .ini cm.save_config() - self.logger.info(f"Settings saved for '{profile_name}'.") - return True + self.logger.info(f"Settings saved successfully for '{profile_name}'.") + status_final = f"Profile '{profile_name}' saved." + success = True + except Exception as e: - self.logger.error(f"Error saving '{profile_name}': {e}", exc_info=True) - self.main_frame.show_error("Save Error", f"Failed save:\n{e}") - return False + self.logger.error(f"Error saving profile '{profile_name}': {e}", exc_info=True) + status_final = f"Error saving profile '{profile_name}'." + mf.show_error("Save Error", f"Failed to save settings:\n{e}") + success = False + finally: + mf.update_status_bar(status_final) + + return success def add_profile(self): """Handles adding a new profile.""" - # (Nessuna modifica necessaria rispetto alla versione precedente) - self.logger.debug("'Add Profile' clicked.") + self.logger.debug("'Add Profile' button clicked.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Adding new profile...") + + # Chiedi nome all'utente name = self.main_frame.ask_new_profile_name() if not name: - self.logger.info("Add cancelled.") - return + self.logger.info("Add profile cancelled by user.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Add profile cancelled.") + return # Esce se l'utente annulla + + # Validazione nome name = name.strip() if not name: - self.logger.warning("Empty name.") - self.main_frame.show_error("Error", "Name empty.") + self.logger.warning("Add profile failed: Name cannot be empty.") + self.main_frame.show_error("Input Error", "Profile name cannot be empty.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Add profile failed: Empty name.") return if name in self.config_manager.get_profile_sections(): - self.logger.warning(f"Exists: '{name}'") - self.main_frame.show_error("Error", f"'{name}' exists.") + self.logger.warning(f"Add profile failed: Profile '{name}' already exists.") + self.main_frame.show_error("Error", f"Profile '{name}' already exists.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(f"Add profile failed: '{name}' exists.") return - self.logger.info(f"Adding profile: '{name}'") + + self.logger.info(f"Attempting to add new profile: '{name}'") + status_final = "Ready." try: - defaults = self.config_manager._get_expected_keys_with_defaults() - defaults["bundle_name"] = f"{name}_repo.bundle" - defaults["bundle_name_updated"] = f"{name}_update.bundle" - defaults["svn_working_copy_path"] = "" - defaults["usb_drive_path"] = "" - for k, v in defaults.items(): - self.config_manager.set_profile_option(name, k, v) - self.config_manager.save_config() - sections = self.config_manager.get_profile_sections() - self.main_frame.update_profile_dropdown(sections) - self.main_frame.profile_var.set(name) - self.logger.info(f"Profile '{name}' added.") + # Chiama ProfileHandler o logica ConfigManager per aggiungere + # (Assumiamo che la logica sia robusta come discusso precedentemente) + success = self.action_handler.add_new_profile(name) # Assumendo che ActionHandler abbia questo metodo + # O chiama direttamente ConfigManager se preferito: + # success = self._add_profile_logic(name) # Esempio se la logica fosse qui + + if success: + self.logger.info(f"Profile '{name}' added successfully.") + # Aggiorna il dropdown nella GUI + sections = self.config_manager.get_profile_sections() + self.main_frame.update_profile_dropdown(sections) + # Seleziona automaticamente il nuovo profilo + self.main_frame.profile_var.set(name) # Questo triggererà load_profile_settings + status_final = f"Profile '{name}' added and loaded." + # Non aggiornare status bar qui, load_profile_settings lo farà + else: + # ActionHandler/ConfigManager dovrebbe aver loggato l'errore + self.logger.error(f"Failed to add profile '{name}' (reported by backend).") + status_final = f"Error adding profile '{name}'." + self.main_frame.show_error("Error", f"Failed to add profile '{name}'.\nCheck logs for details.") + except Exception as e: - self.logger.error(f"Error adding '{name}': {e}", exc_info=True) - self.main_frame.show_error("Error", f"Failed add:\n{e}") + self.logger.error(f"Unexpected error adding profile '{name}': {e}", exc_info=True) + status_final = f"Error adding profile '{name}'." + self.main_frame.show_error("Error", f"Failed to add profile:\n{e}") + finally: + # Non aggiornare status qui se il set() ha triggerato load_profile_settings + # self.main_frame.update_status_bar(status_final) + pass # Lo stato verrà aggiornato dal caricamento def remove_profile(self): """Handles removing the selected profile.""" - # (Nessuna modifica necessaria rispetto alla versione precedente) - self.logger.debug("'Remove Profile' clicked.") + self.logger.debug("'Remove Profile' button clicked.") profile = self.main_frame.profile_var.get() + + # Validazioni iniziali if not profile: - self.logger.warning("No profile.") - self.main_frame.show_error("Error", "No profile.") + self.logger.warning("Remove profile failed: No profile selected.") + self.main_frame.show_error("Error", "No profile selected to remove.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Remove failed: No profile selected.") return if profile == DEFAULT_PROFILE: - self.logger.warning("Cannot remove default.") - self.main_frame.show_error("Error", f"Cannot remove '{DEFAULT_PROFILE}'.") + self.logger.warning("Attempt to remove default profile denied.") + self.main_frame.show_error("Action Denied", f"Cannot remove the default profile ('{DEFAULT_PROFILE}').") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Cannot remove default profile.") return - if self.main_frame.ask_yes_no("Remove Profile", f"Remove '{profile}'?"): - self.logger.info(f"Removing: '{profile}'") + + # Conferma utente + if self.main_frame.ask_yes_no("Confirm Remove", f"Are you sure you want to remove the profile '{profile}'?"): + self.logger.info(f"Attempting to remove profile: '{profile}'") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(f"Removing profile: {profile}...") + + status_final = "Ready." try: - if self.config_manager.remove_profile_section(profile): - self.config_manager.save_config() - self.logger.info("Removed.") + # Esegui rimozione tramite ConfigManager + removed = self.config_manager.remove_profile_section(profile) + if removed: + self.config_manager.save_config() # Salva solo se la rimozione è riuscita + self.logger.info(f"Profile '{profile}' removed successfully.") + status_final = f"Profile '{profile}' removed." + # Aggiorna il dropdown e seleziona il default o il primo disponibile sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(sections) + # Il set() del dropdown triggererà load_profile_settings del nuovo profilo else: - self.main_frame.show_error("Error", "Failed remove.") + # ConfigManager.remove_profile_section dovrebbe loggare il motivo + self.logger.error(f"Failed to remove profile '{profile}' (reported by ConfigManager).") + status_final = f"Error removing profile '{profile}'." + self.main_frame.show_error("Error", f"Could not remove profile '{profile}'.") + except Exception as e: - self.logger.error(f"Error removing: {e}", exc_info=True) - self.main_frame.show_error("Error", f"Error removing:\n{e}") + self.logger.error(f"Error removing profile '{profile}': {e}", exc_info=True) + status_final = f"Error removing profile '{profile}'." + self.main_frame.show_error("Error", f"Failed to remove profile:\n{e}") + finally: + # Non aggiornare qui se load_profile_settings verrà chiamato + # self.main_frame.update_status_bar(status_final) + pass else: - self.logger.info("Removal cancelled.") + self.logger.info("Profile removal cancelled by user.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Profile removal cancelled.") # --- GUI Interaction & Helper Methods --- - # (browse_folder, update_svn_status_indicator, _is_repo_ready, - # _get_and_validate_svn_path, _get_and_validate_usb_path, open_gitignore_editor) - # Mantenere le versioni precedenti (Sezione 3) - NON servono modifiche qui per questa richiesta def browse_folder(self, entry_widget): """Opens folder dialog to update an entry widget.""" - current = entry_widget.get() - initial = current if os.path.isdir(current) else os.path.expanduser("~") + current_path = entry_widget.get() + # Imposta directory iniziale basandosi sul percorso corrente o home utente + initial_dir = current_path if os.path.isdir(current_path) else os.path.expanduser("~") + self.logger.debug(f"Opening folder browser, initial dir: {initial_dir}") + + # Mostra dialogo selezione cartella directory = filedialog.askdirectory( - initialdir=initial, title="Select Directory", parent=self.master + initialdir=initial_dir, + title="Select Directory", + parent=self.master # Assicura che sia modale rispetto alla finestra principale ) + + # Se l'utente seleziona una cartella, aggiorna l'entry widget if directory: + self.logger.debug(f"Directory selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) + # Se l'entry aggiornata è quella del percorso SVN, aggiorna lo stato if ( hasattr(self.main_frame, "svn_path_entry") and entry_widget == self.main_frame.svn_path_entry ): self.update_svn_status_indicator(directory) + else: + self.logger.debug("Folder browse cancelled.") def update_svn_status_indicator(self, svn_path): - """Checks repo status and updates dependent GUI widgets. - Allows 'Fetch from Bundle' if target dir is valid and bundle file exists, - even if the target is not yet a Git repository.""" - # (Mantenere versione precedente robusta) - is_valid_dir = bool(svn_path and os.path.isdir(svn_path)) - # is_repo_ready checks specifically for the presence of '.git' - is_repo_ready = is_valid_dir and os.path.exists(os.path.join(svn_path, ".git")) + """Checks repo status and updates dependent GUI widgets and status bar.""" + # Validazione preliminare del percorso fornito + is_valid_dir_input = bool(svn_path and os.path.isdir(svn_path)) + # Verifica specifica se è un repository Git pronto + is_repo_ready = is_valid_dir_input and os.path.exists(os.path.join(svn_path, ".git")) self.logger.debug( - f"Updating status for '{svn_path}'. ValidDir:{is_valid_dir}, RepoReady:{is_repo_ready}" + f"Updating status for path '{svn_path}'. ValidDir:{is_valid_dir_input}, RepoReady:{is_repo_ready}" ) + + # Verifica esistenza main_frame if not hasattr(self, "main_frame") or not self.main_frame.winfo_exists(): + self.logger.error("Cannot update status indicator: Main frame not available.") return + mf = self.main_frame - # Update indicator color based on whether it's a prepared Git repo - mf.update_svn_indicator(is_repo_ready) # Indicator shows repo readiness + # Aggiorna indicatore colore e tooltip + mf.update_svn_indicator(is_repo_ready) # Questo gestisce colore e tooltip base - # Determine state for most buttons: NORMAL if repo ready, DISABLED otherwise + # Determina lo stato abilitato/disabilitato per i vari widget repo_ready_state = tk.NORMAL if is_repo_ready else tk.DISABLED - # Determine state for buttons needing only a valid directory path - valid_path_state = tk.NORMAL if is_valid_dir else tk.DISABLED + # Stato per widget che richiedono solo un percorso di directory valido (potrebbe non essere repo) + valid_path_state = tk.NORMAL if is_valid_dir_input else tk.DISABLED + # Stato per Prepare: abilitato se path valido MA non è repo pronto + prepare_state = tk.DISABLED if is_repo_ready else valid_path_state - # --- MODIFICA: Logica specifica per abilitare Fetch Button --- - fetch_button_state = tk.DISABLED # Default to disabled + # --- Logica specifica per abilitare Fetch Button (come discusso prima) --- + fetch_button_state = tk.DISABLED # Default disabilitato try: - # Check conditions needed to potentially *clone* from bundle svn_path_str = mf.svn_path_entry.get().strip() usb_path_str = mf.usb_path_entry.get().strip() bundle_fetch_name = mf.bundle_updated_name_entry.get().strip() - # Condition 1: SVN Path validity (must exist or parent must exist) can_create_svn_dir = False if os.path.isdir(svn_path_str): - can_create_svn_dir = True # Directory exists + can_create_svn_dir = True else: parent_dir = os.path.dirname(svn_path_str) - if parent_dir and os.path.isdir(parent_dir): - can_create_svn_dir = True # Parent exists, can create subdir + if parent_dir and os.path.isdir(parent_dir) and svn_path_str: # Assicura che il nome non sia vuoto + can_create_svn_dir = True - # Condition 2: USB Path must be a valid directory is_valid_usb_dir = os.path.isdir(usb_path_str) - - # Condition 3: Fetch bundle name must be provided has_bundle_name = bool(bundle_fetch_name) - - # Condition 4: Fetch bundle file must exist bundle_file_exists = False if is_valid_usb_dir and has_bundle_name: bundle_full_path = os.path.join(usb_path_str, bundle_fetch_name) bundle_file_exists = os.path.isfile(bundle_full_path) - self.logger.debug(f"Checking fetch bundle file: '{bundle_full_path}' - Exists: {bundle_file_exists}") - # Enable Fetch button if: - # - EITHER the repo is already prepared (standard fetch/merge) - # - OR the conditions for cloning from bundle are met if is_repo_ready or (can_create_svn_dir and is_valid_usb_dir and has_bundle_name and bundle_file_exists): fetch_button_state = tk.NORMAL - else: - # Log why fetch is disabled if not obvious - if not is_repo_ready: - self.logger.debug(f"Fetch disabled: Repo not ready and clone conditions not met " - f"(can_create_svn:{can_create_svn_dir}, valid_usb:{is_valid_usb_dir}, " - f"has_name:{has_bundle_name}, file_exists:{bundle_file_exists})") - except Exception as e_check: self.logger.error(f"Error checking conditions for Fetch button state: {e_check}", exc_info=True) - fetch_button_state = tk.DISABLED # Disable on error + fetch_button_state = tk.DISABLED # Sicurezza: disabilita su errore - # Set state for the Fetch button - if hasattr(mf, "fetch_bundle_button"): - mf.fetch_bundle_button.config(state=fetch_button_state) - # --- FINE MODIFICA --- - - - # Update state for other buttons based on original logic + # --- Aggiornamento stato widget --- try: - if hasattr(mf, "edit_gitignore_button"): - # Edit gitignore should arguably only work if .git exists too, - # or be smarter about creating it? Let's tie it to repo_ready for now. - mf.edit_gitignore_button.config(state=repo_ready_state) # CHANGED from valid_path_state + # Pulsanti Tab Repository + if hasattr(mf, "prepare_svn_button"): + mf.prepare_svn_button.config(state=prepare_state) if hasattr(mf, "create_bundle_button"): mf.create_bundle_button.config(state=repo_ready_state) + if hasattr(mf, "fetch_bundle_button"): + mf.fetch_bundle_button.config(state=fetch_button_state) # Usa stato calcolato + if hasattr(mf, "edit_gitignore_button"): + # Edit gitignore richiede repo pronto (file .git deve esistere) + mf.edit_gitignore_button.config(state=repo_ready_state) + + # Pulsanti Tab Backup if hasattr(mf, "manual_backup_button"): - mf.manual_backup_button.config( - state=valid_path_state # Backup needs only a valid source dir - ) + mf.manual_backup_button.config(state=valid_path_state) # Richiede solo sorgente valida + + # Widget Tab Commit if hasattr(mf, "autocommit_checkbox"): mf.autocommit_checkbox.config(state=repo_ready_state) if hasattr(mf, "commit_message_text"): - # Commit message text state might depend on autocommit checkbox as well? - # Keeping it simple: enable if repo is ready + # Stato normale se repo pronto, altrimenti disabilitato mf.commit_message_text.config(state=repo_ready_state) if hasattr(mf, "commit_button"): mf.commit_button.config(state=repo_ready_state) + if hasattr(mf, "refresh_changes_button"): + mf.refresh_changes_button.config(state=repo_ready_state) + if hasattr(mf, "changed_files_listbox"): + # La listbox è sempre selezionabile, ma il suo stato logico dipende da repo_ready + if repo_ready_state == tk.DISABLED: + # Pulisci se il repo non è pronto + mf.update_changed_files_list(["(Repository not ready)"]) - # Tag/Branch/History buttons require a ready repo - if hasattr(mf, "refresh_tags_button"): - mf.refresh_tags_button.config(state=repo_ready_state) - if hasattr(mf, "create_tag_button"): - mf.create_tag_button.config(state=repo_ready_state) - if hasattr(mf, "checkout_tag_button"): - mf.checkout_tag_button.config(state=repo_ready_state) - if hasattr(mf, "refresh_branches_button"): - mf.refresh_branches_button.config(state=repo_ready_state) - if hasattr(mf, "create_branch_button"): - mf.create_branch_button.config(state=repo_ready_state) - if hasattr(mf, "checkout_branch_button"): - mf.checkout_branch_button.config(state=repo_ready_state) - if hasattr(mf, "refresh_history_button"): - mf.refresh_history_button.config(state=repo_ready_state) - if hasattr(mf, "history_branch_filter_combo"): - mf.history_branch_filter_combo.config( - state="readonly" if is_repo_ready else tk.DISABLED - ) - if hasattr(mf, "history_text"): - # History text area state should reflect button state - state_hist_text = tk.NORMAL if repo_ready_state == tk.NORMAL else tk.DISABLED - # Need to check if it's currently DISABLED before changing to NORMAL temporarily to clear/update - # This part is handled within update_history_display now. - # Just ensure the conceptual link: history area usable == repo ready. - pass # No direct state change needed here, handled by update method + # Widget Tab Tags, Branches, History (richiedono repo pronto) + widgets_require_ready = [ + mf.refresh_tags_button, mf.create_tag_button, mf.checkout_tag_button, + mf.refresh_branches_button, mf.create_branch_button, mf.checkout_branch_button, + mf.refresh_history_button, mf.history_branch_filter_combo, mf.history_text, + ] + for widget in widgets_require_ready: + if hasattr(mf, widget.winfo_name()): # Controlla se l'attributo esiste prima di usarlo + target_widget = getattr(mf, widget.winfo_name()) + if target_widget and target_widget.winfo_exists(): + current_state = repo_ready_state + if isinstance(target_widget, ttk.Combobox): + target_widget.config(state="readonly" if current_state == tk.NORMAL else tk.DISABLED) + elif isinstance(target_widget, (tk.Text, scrolledtext.ScrolledText)): + # Lo stato di history_text viene gestito da update_history_display + if target_widget != mf.history_text: + target_widget.config(state=current_state) + else: # Buttons, etc. + target_widget.config(state=current_state) - - # Prepare button state is inverse of repo_ready - if hasattr(mf, "prepare_svn_button"): - prepare_state = tk.DISABLED if is_repo_ready else valid_path_state # Can prepare if path valid & not ready - mf.prepare_svn_button.config(state=prepare_state) + # Pulisci le liste se il repo non è pronto (spostato in load_profile_settings) except Exception as e: - self.logger.error(f"Error updating widget states: {e}", exc_info=True) + self.logger.error(f"Error updating widget states based on repo status: {e}", exc_info=True) + def _is_repo_ready(self, repo_path): - """Helper function to check if a given path points to a valid, prepared Git repo.""" + """Helper function: Checks if path is a valid dir containing .git.""" + # Verifica che il percorso sia non vuoto, sia una directory e contenga .git return bool( repo_path and os.path.isdir(repo_path) @@ -583,83 +689,101 @@ class GitSvnSyncApp: ) def _parse_exclusions(self): - """ - Parses exclusion strings (extensions and directories) from the GUI variables. - - Returns: - tuple: (excluded_extensions_set, excluded_dirs_set) - Both sets contain lowercase strings. - excluded_dirs_set includes default (.git, .svn) and custom dirs. - """ - # (Mantenere versione precedente robusta) + """Parses exclusion strings (extensions and dirs) from GUI vars.""" + # Verifica esistenza main_frame if not hasattr(self, "main_frame") or not self.main_frame.winfo_exists(): + # Ritorna valori predefiniti sicuri se la GUI non è pronta return set(), {".git", ".svn"} + mf = self.main_frame extensions_set = set() - dirs_set = {".git", ".svn"} # Start with defaults + # Inizia con le directory sempre escluse + dirs_set = {".git", ".svn"} + # Parse Extensions exclude_ext_str = mf.backup_exclude_extensions_var.get() if exclude_ext_str: - for ext in exclude_ext_str.split(","): + for ext in exclude_ext_str.split(','): clean_ext = ext.strip().lower() if clean_ext: - extensions_set.add( - "." + clean_ext if not clean_ext.startswith(".") else clean_ext - ) + # Aggiungi punto iniziale se manca + if not clean_ext.startswith('.'): + clean_ext = '.' + clean_ext + extensions_set.add(clean_ext) + # Parse Directories - exclude_dirs_str = mf.backup_exclude_dirs_var.get() # <<< Legge dalla nuova var + exclude_dirs_str = mf.backup_exclude_dirs_var.get() if exclude_dirs_str: - for dir_name in exclude_dirs_str.split(","): - clean_dir = dir_name.strip().lower().strip("/\\") - if clean_dir and clean_dir not in (".", ".."): + for dir_name in exclude_dirs_str.split(','): + # Rimuovi spazi, converti in minuscolo, rimuovi eventuali slash iniziali/finali + clean_dir = dir_name.strip().lower().strip(os.path.sep + '/') + # Ignora nomi non validi o relativi alla directory corrente/parent + if clean_dir and clean_dir not in {".", "..", ""}: dirs_set.add(clean_dir) + self.logger.debug( - f"Parsed Exclusions - Exts: {extensions_set}, Dirs: {dirs_set}" + f"Parsed Exclusions - Extensions: {extensions_set}, Directories: {dirs_set}" ) return extensions_set, dirs_set def _get_and_validate_svn_path(self, operation_name="Operation"): - """Retrieves and validates the SVN Working Copy Path from the GUI.""" - # (Mantenere versione precedente robusta) - if not hasattr(self, "main_frame") or not hasattr( - self.main_frame, "svn_path_entry" - ): + """Retrieves and validates the SVN Working Copy Path exists and is a directory.""" + # Verifica esistenza main_frame + if not hasattr(self, "main_frame") or not hasattr(self.main_frame, "svn_path_entry"): + self.logger.error(f"{operation_name} failed: SVN path entry widget not found.") return None + svn_path_str = self.main_frame.svn_path_entry.get().strip() if not svn_path_str: + self.logger.warning(f"{operation_name} failed: SVN Working Copy Path is empty.") self.main_frame.show_error( "Input Error", "SVN Working Copy Path cannot be empty." ) return None + + # Ottieni percorso assoluto abs_path = os.path.abspath(svn_path_str) + + # Controlla se è una directory valida if not os.path.isdir(abs_path): + self.logger.warning(f"{operation_name} failed: Invalid SVN path (not a directory): {abs_path}") self.main_frame.show_error( "Input Error", f"Invalid SVN path (not a directory):\n{abs_path}" ) return None + + # Se tutto ok, logga e ritorna il percorso assoluto self.logger.debug(f"{operation_name}: Using validated SVN path: {abs_path}") return abs_path def _get_and_validate_usb_path(self, operation_name="Operation"): - """Retrieves and validates the Bundle Target Directory path from the GUI.""" - # (Mantenere versione precedente robusta) - if not hasattr(self, "main_frame") or not hasattr( - self.main_frame, "usb_path_entry" - ): + """Retrieves and validates the Bundle Target Directory path.""" + # Verifica esistenza main_frame + if not hasattr(self, "main_frame") or not hasattr(self.main_frame, "usb_path_entry"): + self.logger.error(f"{operation_name} failed: Bundle target entry widget not found.") return None + usb_path_str = self.main_frame.usb_path_entry.get().strip() if not usb_path_str: + self.logger.warning(f"{operation_name} failed: Bundle Target Directory path is empty.") self.main_frame.show_error( "Input Error", "Bundle Target Directory path cannot be empty." ) return None + + # Ottieni percorso assoluto abs_path = os.path.abspath(usb_path_str) + + # Controlla se è una directory valida if not os.path.isdir(abs_path): + self.logger.warning(f"{operation_name} failed: Invalid Bundle Target path (not a directory): {abs_path}") self.main_frame.show_error( "Input Error", f"Invalid Bundle Target path (not a directory):\n{abs_path}", ) return None + + # Se tutto ok, logga e ritorna il percorso assoluto self.logger.debug( f"{operation_name}: Using validated Bundle Target path: {abs_path}" ) @@ -669,116 +793,302 @@ class GitSvnSyncApp: """Opens the modal editor window for the .gitignore file and triggers automatic untracking check on successful save.""" self.logger.info("--- Action: Edit .gitignore ---") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Opening .gitignore editor...") + + # Ottieni percorso validato (deve essere un repo pronto per editare .gitignore) svn_path = self._get_and_validate_svn_path("Edit .gitignore") - if not svn_path: - return + if not svn_path or not self._is_repo_ready(svn_path): + # Errore se il percorso non è valido o non è un repo + self.logger.warning("Cannot edit .gitignore: Repository path is invalid or not ready.") + self.main_frame.show_error("Error", "Select a valid and prepared repository path first.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Edit .gitignore failed: Repository not ready.") + return + + # Costruisci percorso .gitignore gitignore_path = os.path.join(svn_path, ".gitignore") self.logger.debug(f"Target .gitignore path: {gitignore_path}") + status_final = "Ready." # Default per il finally try: self.logger.debug("Opening GitignoreEditorWindow...") - # --- MODIFICA: Passa il metodo _handle_gitignore_save come callback --- + # Apri la finestra, passando il callback GitignoreEditorWindow( self.master, gitignore_path, self.logger, - on_save_success_callback=self._handle_gitignore_save # Passa il riferimento al metodo + on_save_success_callback=self._handle_gitignore_save ) - # --- FINE MODIFICA --- - # Execution waits here until the Toplevel window is closed + # Esecuzione attende qui finché la finestra non è chiusa self.logger.debug("Gitignore editor window closed.") - # Note: The untracking logic is now triggered *by* the callback *before* the window closes. - # We might still want to refresh UI elements *after* it closes. - # Refresh status indicator and potentially history/branches after editor closes - self.update_svn_status_indicator(svn_path) + # La status bar verrà aggiornata dal callback o impostata a Ready qui sotto + # Aggiorna liste dopo la chiusura, poiché untrack potrebbe aver creato un commit + self.update_svn_status_indicator(svn_path) # Aggiorna stato generale pulsanti self.refresh_commit_history() - self.refresh_branch_list() # Commit might affect branch display + self.refresh_branch_list() + self.refresh_changed_files_list() # Ricarica lista cambiamenti + status_final = "Ready." # Imposta a Ready se non ci sono stati errori except Exception as e: self.logger.exception(f"Error during .gitignore editing or post-save action: {e}") + status_final = "Error during .gitignore edit." self.main_frame.show_error("Editor Error", f"An error occurred:\n{e}") + finally: + if hasattr(self, 'main_frame'): + # Aggiorna status bar solo se non è già stato impostato dal callback + if self.main_frame.status_bar_var.get().startswith("Processing"): + self.main_frame.update_status_bar(status_final) - # --- Threading Helpers (REMOVED) --- - # Rimuovi _run_action_with_wait - # Rimuovi _check_thread_status - # --- Action Callbacks (Modified to be Synchronous and pass exclusions) --- + def _handle_gitignore_save(self): + """ + Callback function triggered after .gitignore is saved successfully. + Initiates the process to untrack newly ignored files. + """ + self.logger.info("Callback triggered: .gitignore saved. Checking for files to untrack automatically...") + # Necessario ottenere di nuovo il percorso SVN + # Usa la validazione base, perché il repo *deve* esistere a questo punto + svn_path = self._get_and_validate_svn_path("Automatic Untracking") + if not svn_path: + self.logger.error("Cannot perform automatic untracking: Invalid SVN path after save.") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Error: Cannot untrack (invalid path).") + return + + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Processing: Checking files to untrack...") + + status_final = "Ready." # Default + try: + # Chiama la logica in ActionHandler + untracked_committed = self.action_handler.execute_untrack_files_from_gitignore(svn_path) + + if untracked_committed: + status_final = "Automatic untrack commit created." + # Mostra info all'utente (opzionale, il log è già chiaro) + # self.main_frame.show_info("Automatic Untrack", f"{status_final}\nCheck log for details.") + else: + status_final = "Automatic untrack check complete (no action needed)." + self.logger.info(status_final) + + except (GitCommandError, ValueError) as e: + self.logger.error(f"Automatic untracking failed: {e}", exc_info=True) + status_final = f"Error during automatic untrack: {type(e).__name__}" + self.main_frame.show_error("Untrack Error", f"Failed automatic untracking:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected error during automatic untracking: {e}") + status_final = "Error: Unexpected untracking failure." + self.main_frame.show_error("Untrack Error", f"Unexpected error during untracking:\n{e}") + finally: + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(status_final) + # È importante ricaricare la lista dei file modificati DOPO questa operazione + self.refresh_changed_files_list() + + + # --- Nuovi Metodi per Commit Tab --- + + def refresh_changed_files_list(self): + """Refreshes the list of changed files in the Commit tab.""" + self.logger.info("--- Action Triggered: Refresh Changed Files List ---") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Processing: Refreshing changed files...") + + # Usa la validazione base, il repo deve esistere + svn_path = self._get_and_validate_svn_path("Refresh Changed Files") + files_status_list = [] # Default lista vuota + status_final = "Ready." + + if svn_path and self._is_repo_ready(svn_path): + try: + # Ottieni stato short da GitCommands + files_status_list = self.git_commands.get_status_short(svn_path) + count = len(files_status_list) + self.logger.info(f"Found {count} changed/untracked items.") + status_final = f"Ready ({count} changes detected)." if count > 0 else "Ready (No changes detected)." + except Exception as e: + self.logger.error(f"Failed refresh changed files list: {e}", exc_info=True) + files_status_list = ["(Error refreshing list)"] # Mostra errore nella lista + status_final = "Error refreshing changes list." + if hasattr(self, 'main_frame'): + self.main_frame.show_error("Error", f"Could not refresh changed files list:\n{e}") + else: + self.logger.debug("Refresh Changed Files skipped: Repository not ready.") + files_status_list = ["(Repository not ready)"] + status_final = "Ready (Repository not ready)." + + # Aggiorna la GUI Listbox + if hasattr(self.main_frame, "update_changed_files_list"): + self.main_frame.update_changed_files_list(files_status_list) + + # Aggiorna status bar alla fine + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(status_final) + + + def open_diff_viewer(self, file_status_line): + """Opens the Diff Viewer window for the selected file status line.""" + # Logga la riga *originale* ricevuta + self.logger.info(f"--- Action Triggered: Open Diff Viewer for '{file_status_line}' ---") + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(f"Processing: Opening diff viewer...") + + svn_path = self._get_and_validate_svn_path("Open Diff Viewer") + if not svn_path: + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar("Error: Cannot open diff (invalid repo path).") + return + + # --- MODIFICA: Rimuovi l'estrazione del path da qui --- + # Pulisci solo null e spazi esterni dalla riga COMPLETA per il controllo status code + cleaned_line_for_status_check = file_status_line.strip('\x00').strip() + if not cleaned_line_for_status_check or len(cleaned_line_for_status_check) < 3: + self.logger.warning(f"Invalid status line received: '{file_status_line}'") + if hasattr(self, 'main_frame'): + self.main_frame.show_warning("Error", "Invalid file status line selected.") + self.main_frame.update_status_bar("Error: Invalid selection.") + return + + # Estrai solo lo status code qui per il controllo preliminare + status_code_part = cleaned_line_for_status_check[:2].strip() + + # Controlla se il file è adatto per il diff + if status_code_part in ["??", "!!", "D"]: + # Estrai il nome file SOLO per il messaggio di errore (usa la nuova logica qui per testarla) + temp_cleaner = DiffViewerWindow._clean_relative_path # Metodo statico o di istanza temporanea + display_path = temp_cleaner(self, cleaned_line_for_status_check) # Passa self se non statico + msg = f"Cannot show diff for untracked, ignored, or deleted file:\n{display_path}" + self.logger.info(f"Diff not applicable: {msg}") + if hasattr(self, 'main_frame'): + self.main_frame.show_info("Diff Not Applicable", msg) + self.main_frame.update_status_bar("Diff not applicable.") + return + # --- FINE MODIFICA --- + + + # Ora apri la finestra passando la RIGA DI STATO ORIGINALE + self.logger.debug(f"Opening DiffViewerWindow with status line: '{file_status_line}'") + status_final = "Ready." + try: + # --- MODIFICA: Passa file_status_line originale --- + DiffViewerWindow( + self.master, + self.logger, + self.git_commands, + svn_path, + file_status_line # <<< Passa la riga intera qui + ) + # --- FINE MODIFICA --- + + self.logger.debug("Diff viewer window closed.") + status_final = "Ready." + + except Exception as e: + self.logger.exception(f"Error opening or running diff viewer: {e}") + status_final = "Error: Failed to open diff viewer." + if hasattr(self, 'main_frame'): + self.main_frame.show_error("Diff Viewer Error", f"Could not display diff:\n{e}") + finally: + if hasattr(self, 'main_frame'): + self.main_frame.update_status_bar(status_final) + + + # --- Action Callbacks (Synchronous) --- + # (Aggiungere chiamate a update_status_bar e refresh_changed_files_list dove necessario) def prepare_svn_for_git(self): """Handles the 'Prepare SVN Repo' action (Synchronous).""" self.logger.info("--- Action Triggered: Prepare Repo ---") - self.main_frame.update_status_bar("Processing: Preparing repository...") # Validazione input svn_path = self._get_and_validate_svn_path("Prepare Repository") if not svn_path: + # Errore già mostrato da _get_and_validate... + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Prepare failed: Invalid path.") return - # Esecuzione sincrona + + # Aggiorna Status Bar + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Preparing repository...") + status_final = "Ready." # Default + success = False try: # Chiamata diretta a ActionHandler - self.action_handler.execute_prepare_repo(svn_path) - self.main_frame.show_info("Success", "Repository prepared successfully.") - self.main_frame.update_status_bar("Repository prepared.") - except (ValueError, GitCommandError, IOError) as e: + success = self.action_handler.execute_prepare_repo(svn_path) + if success: + self.main_frame.show_info("Success", "Repository prepared successfully.") + status_final = "Repository prepared." + # Se execute_prepare_repo solleva ValueError("already prepared"), viene gestito sotto + except ValueError as e: # Cattura "already prepared" + self.logger.warning(f"Prepare Repo info: {e}") + status_final = "Repository already prepared." + self.main_frame.show_warning("Prepare Info", f"{e}") + success = True # Consideralo un successo funzionale + except (GitCommandError, IOError) as e: self.logger.error(f"Prepare Repo failed: {e}", exc_info=True) - # Mostra errore specifico (ValueError è spesso per 'già preparato') - if isinstance(e, ValueError): - self.main_frame.show_warning("Prepare Info", f"{e}") - else: - self.main_frame.show_error( - "Prepare Error", f"Failed to prepare repository:\n{e}" - ) + status_final = f"Error preparing repository: {type(e).__name__}" + self.main_frame.show_error("Prepare Error", f"Failed to prepare repository:\n{e}") except Exception as e: self.logger.exception(f"Unexpected error during Prepare Repo: {e}") - self.main_frame.show_error( - "Unexpected Error", f"An unexpected error occurred:\n{e}" - ) + status_final = "Error: Unexpected preparation failure." + self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred:\n{e}") finally: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) # Aggiorna stato UI in ogni caso dopo l'operazione self.update_svn_status_indicator(svn_path) + if success: # Aggiorna liste solo se preparato con successo (o già pronto) + self.refresh_changed_files_list() + def create_git_bundle(self): """Handles the 'Create Bundle' action (Synchronous).""" self.logger.info("--- Action Triggered: Create Bundle ---") - self.main_frame.update_status_bar("Processing: Creating bundle...") - - status_final = "Ready." # Validazione Input e Preparazione Argomenti profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Create bundle failed: No profile.") return svn_path = self._get_and_validate_svn_path("Create Bundle") if not svn_path: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Create bundle failed: Invalid repo path.") return usb_path = self._get_and_validate_usb_path("Create Bundle") if not usb_path: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Create bundle failed: Invalid target path.") return bundle_name = self.main_frame.bundle_name_entry.get().strip() if not bundle_name: - self.main_frame.show_error("Input Error", "Bundle filename empty.") + self.main_frame.show_error("Input Error", "Bundle filename cannot be empty.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Create bundle failed: Empty filename.") return + # Assicura estensione .bundle if not bundle_name.lower().endswith(".bundle"): bundle_name += ".bundle" - self.main_frame.bundle_name_entry.delete(0, tk.END) - self.main_frame.bundle_name_entry.insert(0, bundle_name) + # Aggiorna GUI se necessario (potrebbe causare ricorsione se trace è attivo?) + # self.main_frame.bundle_name_entry.delete(0, tk.END) + # self.main_frame.bundle_name_entry.insert(0, bundle_name) bundle_full_path = os.path.join(usb_path, bundle_name) + + # Salva profilo prima dell'azione if not self.save_profile_settings(): if not self.main_frame.ask_yes_no( "Save Warning", "Could not save profile settings.\nContinue anyway?" ): - return - # --- MODIFICA: Parse exclusions --- + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Create bundle cancelled (save failed).") + return + + # Parse esclusioni e opzioni excluded_extensions, excluded_dirs = self._parse_exclusions() - # --- FINE MODIFICA --- autobackup_enabled = self.main_frame.autobackup_var.get() backup_base_dir = self.main_frame.backup_dir_var.get() autocommit_enabled = self.main_frame.autocommit_var.get() commit_message = self.main_frame.get_commit_message() # Esecuzione sincrona + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Creating bundle...") + status_final = "Ready." # Default per finally try: - # --- MODIFICA: Passa i set parsati a ActionHandler --- bundle_path_result = self.action_handler.execute_create_bundle( svn_path, bundle_full_path, @@ -788,9 +1098,9 @@ class GitSvnSyncApp: autocommit_enabled, commit_message, excluded_extensions, - excluded_dirs, # Passa i set qui + excluded_dirs, ) - # --- FINE MODIFICA --- + if bundle_path_result: self.main_frame.show_info( "Bundle Created", f"Bundle created:\n{bundle_path_result}" @@ -799,171 +1109,197 @@ class GitSvnSyncApp: else: self.main_frame.show_info( "Bundle Info", - "Bundle creation finished, but no file generated (repo empty?).", + "Bundle creation finished, but no file generated (repo empty or no changes?).", ) status_final = "Bundle created (empty or no changes)." + except (IOError, GitCommandError, ValueError) as e: self.logger.error(f"Create Bundle failed: {e}", exc_info=True) - status_final = "Error: Unexpected failure creating bundle." + status_final = f"Error creating bundle: {type(e).__name__}" self.main_frame.show_error("Create Bundle Error", f"Operation failed:\n{e}") - except Exception as e: self.logger.exception(f"Unexpected error during Create Bundle: {e}") - status_final = f"Error creating bundle: {type(e).__name__}" + status_final = "Error: Unexpected failure creating bundle." self.main_frame.show_error( "Unexpected Error", f"An unexpected error occurred:\n{e}" ) - finally: - # --- MODIFICA: Status Bar (Fine Operazione) --- - self.main_frame.update_status_bar(status_final) - # --- FINE MODIFICA --- - # Aggiorna stato UI (invariato) + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + # Aggiorna stato UI (importante se c'è stato autocommit) self.update_svn_status_indicator(svn_path) + self.refresh_commit_history() # Mostra nuovo commit + self.refresh_changed_files_list() # Aggiorna lista cambiamenti + def fetch_from_git_bundle(self): """Handles the 'Fetch from Bundle' action (Synchronous).""" self.logger.info("--- Action Triggered: Fetch from Bundle ---") - self.main_frame.update_status_bar("Processing: Fetching from bundle...") - status_final = "Ready." - # Validazione Input e Preparazione Argomenti + # Validazione Profilo profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Fetch failed: No profile.") return - svn_path = self._get_and_validate_svn_path("Fetch from Bundle") - if not svn_path: - return - usb_path = self._get_and_validate_usb_path("Fetch from Bundle") + + # Ottieni percorsi stringa (validazione interna ad ActionHandler) + svn_path_str = self.main_frame.svn_path_entry.get().strip() + if not svn_path_str: + self.main_frame.show_error("Input Error", "SVN Working Copy Path cannot be empty.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Fetch failed: Empty repo path.") + return + usb_path = self._get_and_validate_usb_path("Fetch from Bundle") # Valida percorso USB if not usb_path: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Fetch failed: Invalid target path.") return bundle_name = self.main_frame.bundle_updated_name_entry.get().strip() if not bundle_name: - self.main_frame.show_error("Input Error", "Fetch bundle filename empty.") + self.main_frame.show_error("Input Error", "Fetch bundle filename cannot be empty.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Fetch failed: Empty filename.") return bundle_full_path = os.path.join(usb_path, bundle_name) - # Controllo file spostato in ActionHandler, ma potrebbe rimanere qui per feedback UI più rapido - if not os.path.isfile(bundle_full_path): - self.main_frame.show_error( - "File Not Found", f"Bundle file not found:\n{bundle_full_path}" - ) - return + + # Salva profilo if not self.save_profile_settings(): if not self.main_frame.ask_yes_no( "Save Warning", "Could not save profile settings.\nContinue anyway?" ): + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Fetch cancelled (save failed).") return - # --- MODIFICA: Parse exclusions --- + + # Parse opzioni excluded_extensions, excluded_dirs = self._parse_exclusions() - # --- FINE MODIFICA --- autobackup_enabled = self.main_frame.autobackup_var.get() backup_base_dir = self.main_frame.backup_dir_var.get() - # Esecuzione sincrona + # Esecuzione + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Fetching from bundle...") + status_final = "Ready." try: - # --- MODIFICA: Passa i set parsati a ActionHandler --- + # Chiama ActionHandler (che gestisce clone o fetch/merge) success = self.action_handler.execute_fetch_bundle( - svn_path, + svn_path_str, bundle_full_path, profile, autobackup_enabled, backup_base_dir, excluded_extensions, - excluded_dirs, # Passa i set qui + excluded_dirs, ) - # --- FINE MODIFICA --- - if success: # ActionHandler ora restituisce bool (o solleva eccezione) + + if success: self.main_frame.show_info( - "Fetch Complete", - "Fetch and merge completed.\nCheck log for details.", + "Operation Complete", + "Fetch/Clone completed.\nCheck log for details.", ) status_final = "Fetch/Clone from bundle complete." - # else non dovrebbe accadere se ActionHandler solleva eccezioni - except FileNotFoundError as e: # Cattura se il file scompare tra check e uso + # else: ActionHandler dovrebbe sollevare eccezione su fallimento + + # Gestione Errori Specifici + except FileNotFoundError as e: + self.logger.error(f"Fetch/Clone failed: {e}", exc_info=False) status_final = f"Error: Bundle file not found." - self.logger.error(f"Fetch failed: {e}", exc_info=False) # Log meno verboso self.main_frame.show_error("File Not Found", f"{e}") - except GitCommandError as e: # Gestione specifica conflitti - self.logger.error(f"Fetch failed: {e}", exc_info=True) + except IOError as e: + self.logger.error(f"Fetch/Clone failed: {e}", exc_info=True) + status_final = f"Error: Invalid destination or permissions?" + self.main_frame.show_error("Operation Error", f"Operation failed:\n{e}") + except GitCommandError as e: + self.logger.error(f"Fetch/Clone failed: {e}", exc_info=True) if "merge conflict" in str(e).lower(): + status_final = "Error: Merge conflict detected!" self.main_frame.show_error( "Merge Conflict", - f"Conflict occurred during merge.\nPlease resolve conflicts manually in:\n{svn_path}\nThen commit the changes.", + f"Conflict occurred during merge.\nPlease resolve conflicts manually in:\n{svn_path_str}\nThen commit the changes.", ) else: - self.main_frame.show_error( - "Fetch Error (Git)", f"Operation failed:\n{e}" - ) - except (IOError, ValueError) as e: - self.logger.error(f"Fetch failed: {e}", exc_info=True) - self.main_frame.show_error("Fetch Error", f"Operation failed:\n{e}") + status_final = "Error: Git command failed during fetch/clone." + self.main_frame.show_error("Operation Error (Git)", f"Operation failed:\n{e}") + except ValueError as e: + self.logger.error(f"Fetch/Clone failed: {e}", exc_info=True) + status_final = f"Error: Invalid input for fetch/clone." + self.main_frame.show_error("Input Error", f"Operation failed:\n{e}") except Exception as e: - self.logger.exception(f"Unexpected error during Fetch: {e}") - self.main_frame.show_error( - "Unexpected Error", f"An unexpected error occurred:\n{e}" - ) + self.logger.exception(f"Unexpected error during Fetch/Clone: {e}") + status_final = "Error: Unexpected failure during fetch/clone." + self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred:\n{e}") finally: - self.main_frame.update_status_bar(status_final) - + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + # Aggiorna stato UI e liste dopo l'operazione + self.update_svn_status_indicator(svn_path_str) + self.refresh_commit_history() + self.refresh_tag_list() + self.refresh_branch_list() + self.refresh_changed_files_list() + def manual_backup(self): """Handles the 'Backup Now' button click (Synchronous).""" self.logger.info("--- Action Triggered: Manual Backup ---") - self.main_frame.update_status_bar("Processing: Manual Backup...") # Validazione Input profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Backup failed: No profile.") return svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") if not svn_path: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Backup failed: Invalid source path.") return backup_base_dir = self.main_frame.backup_dir_var.get().strip() if not backup_base_dir: - self.main_frame.show_error("Input Error", "Backup directory path empty.") + self.main_frame.show_error("Input Error", "Backup directory path cannot be empty.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Backup failed: Empty backup directory.") return - if not self.save_profile_settings(): # Salva le esclusioni correnti + + # Salva profilo (per salvare le esclusioni correnti) + if not self.save_profile_settings(): if not self.main_frame.ask_yes_no( "Save Warning", "Could not save profile settings.\nContinue anyway?" ): - return - # --- MODIFICA: Parse exclusions --- + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Backup cancelled (save failed).") + return + + # Parse esclusioni excluded_extensions, excluded_dirs = self._parse_exclusions() - # --- FINE MODIFICA --- # Esecuzione sincrona - self.logger.info(f"Starting manual backup for '{profile}'...") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Creating manual backup...") + status_final = "Ready." try: - # --- MODIFICA: Chiamata diretta a BackupHandler con i set --- - # Non c'è un metodo dedicato in ActionHandler per backup manuale, - # quindi chiamiamo direttamente BackupHandler qui. + # Chiama direttamente BackupHandler backup_path_result = self.backup_handler.create_zip_backup( source_repo_path=svn_path, backup_base_dir=backup_base_dir, profile_name=profile, excluded_extensions=excluded_extensions, - excluded_dirs_base=excluded_dirs, # Passa il set qui + excluded_dirs_base=excluded_dirs, ) - # --- FINE MODIFICA --- + if backup_path_result: self.main_frame.show_info( "Backup Complete", f"Manual backup created:\n{backup_path_result}" ) - self.main_frame.update_status_bar(f"Manual backup created:\n{backup_path_result}") + timestamp = datetime.datetime.now().strftime('%H:%M:%S') # Solo ora per brevità + status_final = f"Manual backup created ({timestamp})." else: self.main_frame.show_warning( "Backup Info", - "Backup finished, but no file generated (source empty?).", + "Backup finished, but no file generated (source empty or all excluded?).", ) - self.main_frame.update_status_bar("Backup finished, but no file generated (source empty?).") - except (IOError, ValueError, PermissionError) as e: # Cattura errori specifici + status_final = "Manual backup finished (no file generated)." + except (IOError, ValueError, PermissionError) as e: self.logger.error(f"Manual backup failed: {e}", exc_info=True) + status_final = f"Error creating backup: {type(e).__name__}" self.main_frame.show_error("Backup Error", f"Manual backup failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected error during manual backup: {e}") + status_final = "Error: Unexpected backup failure." self.main_frame.show_error( "Unexpected Error", f"An unexpected error occurred:\n{e}" ) + finally: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + def commit_changes(self): """Handles the 'Commit Changes' button click (Synchronous).""" @@ -971,14 +1307,19 @@ class GitSvnSyncApp: # Validazione Input svn_path = self._get_and_validate_svn_path("Commit Changes") if not svn_path: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Commit failed: Invalid path.") return commit_msg = self.main_frame.get_commit_message() if not commit_msg: self.main_frame.show_error( "Commit Error", "Commit message cannot be empty." ) + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Commit failed: Empty message.") return + # Esecuzione sincrona + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Committing changes...") + status_final = "Ready." try: commit_made = self.action_handler.execute_manual_commit( svn_path, commit_msg @@ -986,94 +1327,111 @@ class GitSvnSyncApp: if commit_made: self.main_frame.show_info("Commit Successful", "Changes committed.") self.main_frame.clear_commit_message() - self.refresh_commit_history() # Aggiorna UI dopo successo + status_final = "Commit successful." + # Aggiorna UI dopo successo + self.refresh_commit_history() self.refresh_branch_list() + self.refresh_tag_list() # Commit potrebbe abilitare nuovi tag + self.refresh_changed_files_list() # Lista dovrebbe svuotarsi else: self.main_frame.show_info( "No Changes", "No changes were available to commit." ) + status_final = "Commit finished (no changes)." + # Aggiorna comunque lista cambiamenti + self.refresh_changed_files_list() + + except (GitCommandError, ValueError) as e: self.logger.error(f"Commit failed: {e}", exc_info=True) + status_final = f"Error during commit: {type(e).__name__}" self.main_frame.show_error("Commit Error", f"Failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected commit error: {e}") + status_final = "Error: Unexpected commit failure." self.main_frame.show_error("Error", f"Unexpected:\n{e}") + finally: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + # --- Tag Management Callbacks (Synchronous) --- def refresh_tag_list(self): """Refreshes the tag list (Synchronous).""" self.logger.info("--- Action Triggered: Refresh Tags ---") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Refreshing tags...") + svn_path = self._get_and_validate_svn_path("Refresh Tags") tags_data = [] # Default a lista vuota + status_final = "Ready." if svn_path and self._is_repo_ready(svn_path): try: tags_data = self.git_commands.list_tags(svn_path) - self.logger.info(f"Tag list refreshed ({len(tags_data)} tags found).") + count = len(tags_data) + self.logger.info(f"Tag list refreshed ({count} tags found).") + status_final = f"Tags refreshed ({count} found)." except Exception as e: self.logger.error(f"Failed refresh tags: {e}", exc_info=True) + status_final = "Error refreshing tags." self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") + tags_data = [("(Error loading tags)", "")] # Mostra errore nella lista else: self.logger.debug("Refresh Tags skipped: Repository not ready.") + status_final = "Ready (Repository not ready)." + tags_data = [("(Repository not ready)", "")] + # Aggiorna GUI in ogni caso (anche con lista vuota o errore) if hasattr(self.main_frame, "update_tag_list"): self.main_frame.update_tag_list(tags_data) - + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + + def _generate_next_tag_suggestion(self, svn_path): - """ - Generates a suggested tag name based on the latest tag matching v.X.X.X.X pattern. - - Args: - svn_path (str): Path to the repository. - - Returns: - str: The suggested tag name (e.g., "v.0.0.0.1" or incremented version). - """ + """Generates suggested tag name based on latest v.X.X.X.X tag.""" + # (Implementazione come discussa precedentemente) self.logger.debug("Generating next tag suggestion...") default_suggestion = "v.0.0.0.1" latest_valid_tag = None - tag_pattern = re.compile(r"^v\.(\d{1,2})\.(\d{1,2})\.(\d{1,2})\.(\d{1,2})$") + # Pattern più robusto: permette v. (numeri separati da punti) + tag_pattern = re.compile(r"^v\.(\d+)\.(\d+)\.(\d+)\.(\d+)$") try: - # Ottieni i tag ordinati, dal più recente - # list_tags restituisce [(name, subject), ...] tags_data = self.git_commands.list_tags(svn_path) if not tags_data: self.logger.debug("No existing tags found. Suggesting default.") return default_suggestion - # Cerca il primo tag che corrisponde al pattern - for tag_name, _ in tags_data: + for tag_name, _ in tags_data: # Itera sui tag (dal più recente) match = tag_pattern.match(tag_name) if match: latest_valid_tag = tag_name self.logger.debug(f"Found latest tag matching pattern: {latest_valid_tag}") - break # Trovato il più recente, esci + break if not latest_valid_tag: self.logger.debug("No tags matched the pattern v.X.X.X.X. Suggesting default.") return default_suggestion - # Estrai e incrementa i numeri - match = tag_pattern.match(latest_valid_tag) # Riesegui match per sicurezza - if not match: # Non dovrebbe succedere, ma controllo difensivo + match = tag_pattern.match(latest_valid_tag) + if not match: self.logger.error(f"Internal error: Could not re-match tag {latest_valid_tag}") return default_suggestion + # Incrementa i numeri, assumendo formato v1.v2.v3.v4 + # Qui usiamo la logica con riporto a 99 come richiesto v1, v2, v3, v4 = map(int, match.groups()) + limit = 99 # Limite per il riporto - # Incrementa gestendo i riporti da 99 a 0 v4 += 1 - if v4 > 99: + if v4 > limit: v4 = 0 v3 += 1 - if v3 > 99: + if v3 > limit: v3 = 0 v2 += 1 - if v2 > 99: + if v2 > limit: v2 = 0 v1 += 1 - # Non mettiamo limiti a v1 per ora (può diventare > 99) next_tag = f"v.{v1}.{v2}.{v3}.{v4}" self.logger.debug(f"Generated suggestion: {next_tag}") @@ -1081,282 +1439,392 @@ class GitSvnSyncApp: except Exception as e: self.logger.error(f"Error generating tag suggestion: {e}", exc_info=True) - return default_suggestion # Ritorna il default in caso di errore + return default_suggestion + def create_tag(self): - """Handles 'Create Tag' (Synchronous after dialog), suggesting the next tag name.""" + """Handles 'Create Tag' (Synchronous after dialog), suggesting name and committing first.""" self.logger.info("--- Action Triggered: Create Tag ---") svn_path = self._get_and_validate_svn_path("Create Tag") if not svn_path: - return + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Create tag failed: Invalid path.") + return - # --- MODIFICA: Genera suggerimento PRIMA di aprire il dialogo --- + # Genera suggerimento PRIMA di aprire il dialogo + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Generating tag suggestion...") suggested_tag = self._generate_next_tag_suggestion(svn_path) - # --- FINE MODIFICA --- + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Ready for tag input.") # Aggiorna dopo il calcolo # Ottieni input utente (Dialog sincrono) - # --- MODIFICA: Passa il suggerimento al Dialog --- dialog = CreateTagDialog(self.master, suggested_tag_name=suggested_tag) - # --- FINE MODIFICA --- - tag_info = dialog.result + tag_info = dialog.result # Attende chiusura dialogo if tag_info: tag_name, tag_message = tag_info - # (Logica commit pre-tag e chiamata ad action_handler invariata) - if not self.save_profile_settings(): - if not self.main_frame.ask_yes_no( - "Save Warning", "Could not save profile settings.\nContinue anyway?" - ): - return - commit_message = self.main_frame.get_commit_message() + self.logger.info(f"User provided tag name: '{tag_name}', message: '{tag_message[:50]}...'") + # Non serve più salvare il profilo o ottenere commit_message dalla GUI + # commit_message = self.main_frame.get_commit_message() # Rimosso + + # Esecuzione sincrona + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(f"Processing: Creating tag '{tag_name}'...") + status_final = "Ready." try: + # Chiama ActionHandler che ora fa commit + tag success = self.action_handler.execute_create_tag( - svn_path, commit_message, tag_name, tag_message + svn_path, + None, # Passa None per il messaggio commit GUI (ignorato) + tag_name, + tag_message ) if success: - self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") + status_final = f"Tag '{tag_name}' created successfully." + self.main_frame.show_info("Success", status_final) + # Aggiorna UI dopo successo self.refresh_tag_list() self.refresh_commit_history() self.refresh_branch_list() + self.refresh_changed_files_list() # Commit ha pulito i cambiamenti except (GitCommandError, ValueError) as e: self.logger.error(f"Failed create tag '{tag_name}': {e}", exc_info=True) + status_final = f"Error creating tag: {type(e).__name__}" self.main_frame.show_error("Tag Error", f"Failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected tag error: {e}") + status_final = "Error: Unexpected tag failure." self.main_frame.show_error("Error", f"Unexpected:\n{e}") + finally: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) else: - self.logger.info("Tag creation cancelled.") + self.logger.info("Tag creation cancelled by user.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Tag creation cancelled.") + def checkout_tag(self): """Handles the 'Checkout Selected Tag' action (Synchronous).""" self.logger.info("--- Action Triggered: Checkout Tag ---") svn_path = self._get_and_validate_svn_path("Checkout Tag") if not svn_path: - return + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Checkout failed: Invalid path.") + return + selected_tag = self.main_frame.get_selected_tag() if not selected_tag: - self.main_frame.show_error("Error", "Select tag.") + self.main_frame.show_error("Selection Error", "No tag selected from the list.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Checkout failed: No tag selected.") return - # Conferma utente - confirm_msg = f"Checkout tag '{selected_tag}'?\n\nWARNINGS:\n- Files overwritten.\n- NO backup.\n- Detached HEAD state." - if not self.main_frame.ask_yes_no("Confirm Checkout", confirm_msg): - self.logger.info("Checkout cancelled.") + + # Conferma utente (importante per checkout!) + confirm_msg = (f"This will checkout the tag '{selected_tag}', potentially overwriting " + "local changes and entering a 'detached HEAD' state.\n\n" + "Make sure you have committed or stashed any important work.\n\n" + "Proceed with checkout?") + if not self.main_frame.ask_yes_no("Confirm Checkout Tag", confirm_msg): + self.logger.info("Tag checkout cancelled by user.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Tag checkout cancelled.") return + # Esecuzione sincrona + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(f"Processing: Checking out tag '{selected_tag}'...") + status_final = "Ready." try: + # ActionHandler ora dovrebbe verificare cambiamenti prima del checkout success = self.action_handler.execute_checkout_tag(svn_path, selected_tag) if success: + status_final = f"Checked out tag '{selected_tag}' (Detached HEAD)." self.main_frame.show_info( - "Success", - f"Checked out '{selected_tag}'.\n\nNOTE: Detached HEAD state.", + "Checkout Successful", + status_final + "\n\nNOTE: You are now in a 'detached HEAD' state." ) - self.refresh_branch_list() - self.refresh_commit_history() # Aggiorna UI - except (GitCommandError, ValueError) as e: - self.logger.error(f"Failed checkout '{selected_tag}': {e}", exc_info=True) - self.main_frame.show_error("Error", f"Checkout failed:\n{e}") + # Aggiorna UI dopo successo + self.refresh_branch_list() # Mostra stato detached + self.refresh_commit_history() + self.refresh_changed_files_list() # Checkout cambia i file + except ValueError as e: # Cattura errore "Uncommitted changes" da ActionHandler + self.logger.error(f"Checkout tag '{selected_tag}' failed: {e}", exc_info=False) # Meno verboso per questo errore comune + status_final = f"Checkout failed: Uncommitted changes." + self.main_frame.show_warning("Checkout Blocked", f"{e}\nPlease commit or stash changes first.") + except GitCommandError as e: + self.logger.error(f"Failed checkout tag '{selected_tag}': {e}", exc_info=True) + status_final = f"Error checking out tag: {type(e).__name__}" + self.main_frame.show_error("Checkout Error", f"Checkout failed:\n{e}") except Exception as e: - self.logger.exception(f"Unexpected checkout error: {e}") + self.logger.exception(f"Unexpected checkout tag error: {e}") + status_final = "Error: Unexpected checkout failure." self.main_frame.show_error("Error", f"Unexpected:\n{e}") + finally: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + # --- Branch Management Callbacks (Synchronous) --- def refresh_branch_list(self): """Refreshes the branch list (Synchronous).""" self.logger.info("--- Action Triggered: Refresh Branches ---") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Refreshing branches...") + svn_path = self._get_and_validate_svn_path("Refresh Branches") branches, current = [], None # Default + status_final = "Ready." + if svn_path and self._is_repo_ready(svn_path): try: branches, current = self.git_commands.list_branches(svn_path) - self.logger.info( - f"Branch list refreshed ({len(branches)} found). Current: {current}" - ) + count = len(branches) + current_display = current if current else "None (Detached?)" + self.logger.info(f"Branch list refreshed ({count} found). Current: {current_display}") + status_final = f"Branches refreshed ({count} found). Current: {current_display}" except Exception as e: self.logger.error(f"Failed refresh branches: {e}", exc_info=True) + status_final = "Error refreshing branches." self.main_frame.show_error("Error", f"Could not refresh branches:\n{e}") + branches = ["(Error loading branches)"] else: self.logger.debug("Refresh Branches skipped: Repository not ready.") + status_final = "Ready (Repository not ready)." + branches = ["(Repository not ready)"] + # Aggiorna GUI if hasattr(self.main_frame, "update_branch_list"): self.main_frame.update_branch_list(branches, current) + # Aggiorna anche il filtro nella scheda History if hasattr(self.main_frame, "update_history_branch_filter"): - self.main_frame.update_history_branch_filter(branches or [], current) + # Passa lista pulita senza messaggi di errore/stato + clean_branches = [b for b in branches if not b.startswith("(")] + self.main_frame.update_history_branch_filter(clean_branches or [], current) + + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + def create_branch(self): """Handles the 'Create Branch' action (Synchronous after dialog).""" self.logger.info("--- Action Triggered: Create Branch ---") svn_path = self._get_and_validate_svn_path("Create Branch") if not svn_path: - return + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Create branch failed: Invalid path.") + return + + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Ready for new branch input.") # Ottieni input utente (Dialog sincrono) dialog = CreateBranchDialog(self.master) - new_branch_name = dialog.result + new_branch_name = dialog.result # Attende chiusura + if new_branch_name: + self.logger.info(f"User provided new branch name: '{new_branch_name}'") # Esecuzione sincrona + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(f"Processing: Creating branch '{new_branch_name}'...") + status_final = "Ready." + checkout_after = False try: success = self.action_handler.execute_create_branch( svn_path, new_branch_name ) if success: - self.main_frame.show_info( - "Success", f"Branch '{new_branch_name}' created." - ) + status_final = f"Branch '{new_branch_name}' created." + self.main_frame.show_info("Success", status_final) + # Aggiorna lista branch immediatamente self.refresh_branch_list() - # Aggiorna lista - # Chiedi checkout (sincrono) + # Chiedi se fare checkout if self.main_frame.ask_yes_no( "Checkout New Branch?", - f"Switch to new branch '{new_branch_name}'?", + f"Switch to the newly created branch '{new_branch_name}'?", ): - self.checkout_branch( - branch_to_checkout=new_branch_name, - repo_path_override=svn_path, - ) # Chiama checkout sync + checkout_after = True # Segna per fare checkout dopo else: - self.refresh_commit_history() # Aggiorna storia anche se non fai checkout + # Aggiorna altre viste anche se non fai checkout + self.refresh_commit_history() + except (GitCommandError, ValueError) as e: - self.logger.error( - f"Failed create branch '{new_branch_name}': {e}", exc_info=True - ) + self.logger.error(f"Failed create branch '{new_branch_name}': {e}", exc_info=True) + status_final = f"Error creating branch: {type(e).__name__}" self.main_frame.show_error("Error", f"Create failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected create branch: {e}") + status_final = "Error: Unexpected create branch failure." self.main_frame.show_error("Error", f"Unexpected:\n{e}") + finally: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + # Esegui checkout se richiesto e la creazione è andata a buon fine + if checkout_after and status_final.startswith("Branch"): + self.checkout_branch( + branch_to_checkout=new_branch_name, + repo_path_override=svn_path # Passa il percorso già validato + ) + + else: - self.logger.info("Branch creation cancelled.") + self.logger.info("Branch creation cancelled by user.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Branch creation cancelled.") + def checkout_branch(self, branch_to_checkout=None, repo_path_override=None): - """Handles 'Checkout Selected Branch' (Synchronous).""" - # (Mantenere versione precedente robusta, ma con chiamate sincrone) - log_target = branch_to_checkout or "Selected" - self.logger.info( - f"--- Action Triggered: Checkout Branch (Target: {log_target}) ---" - ) - svn_path = repo_path_override or self._get_and_validate_svn_path( - "Checkout Branch" - ) + """Handles 'Checkout Selected Branch' (Synchronous). + Can be called internally after create_branch.""" + log_target = branch_to_checkout if branch_to_checkout else "Selected" + self.logger.info(f"--- Action Triggered: Checkout Branch (Target: {log_target}) ---") + + # Ottieni percorso repo (usa override se fornito) + svn_path = repo_path_override or self._get_and_validate_svn_path("Checkout Branch") if not svn_path: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Checkout failed: Invalid path.") return - if branch_to_checkout: - selected_branch = branch_to_checkout - needs_confirmation = False - else: + + # Determina il branch da checkouttare + selected_branch = branch_to_checkout # Usa quello passato se c'è + needs_confirmation = False + if not selected_branch: # Altrimenti prendilo dalla GUI selected_branch = self.main_frame.get_selected_branch() - needs_confirmation = True + needs_confirmation = True # Richiedi conferma solo se l'utente ha cliccato + if not selected_branch: - self.main_frame.show_error("Selection Error", "Select a branch.") + self.main_frame.show_error("Selection Error", "No branch selected from the list.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Checkout failed: No branch selected.") return + + # Conferma (solo se triggerato da click utente) if needs_confirmation: if not self.main_frame.ask_yes_no( - "Confirm Checkout", f"Switch to branch '{selected_branch}'?" + "Confirm Checkout Branch", f"Switch to branch '{selected_branch}'?" ): - self.logger.info("Checkout cancelled.") + self.logger.info("Branch checkout cancelled by user.") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Branch checkout cancelled.") return + # Esecuzione sincrona + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(f"Processing: Checking out branch '{selected_branch}'...") + status_final = "Ready." try: - success = self.action_handler.execute_switch_branch( - svn_path, selected_branch - ) + # ActionHandler ora dovrebbe verificare cambiamenti prima dello switch + success = self.action_handler.execute_switch_branch(svn_path, selected_branch) if success: - self.main_frame.show_info( - "Success", f"Switched to branch '{selected_branch}'." - ) - self.refresh_branch_list() - self.refresh_commit_history() # Aggiorna UI - except (GitCommandError, ValueError) as e: - self.logger.error( - f"Failed checkout '{selected_branch}': {e}", exc_info=True - ) + status_final = f"Switched to branch '{selected_branch}'." + self.main_frame.show_info("Success", status_final) + # Aggiorna UI dopo successo + self.refresh_branch_list() # Aggiorna la lista e la selezione corrente + self.refresh_commit_history() # La storia potrebbe cambiare + self.refresh_tag_list() # I tag visibili potrebbero cambiare (anche se raramente) + self.refresh_changed_files_list() # Lo stato dei file cambia + except ValueError as e: # Cattura errore "Uncommitted changes" da ActionHandler + self.logger.error(f"Checkout branch '{selected_branch}' failed: {e}", exc_info=False) + status_final = f"Checkout failed: Uncommitted changes." + self.main_frame.show_warning("Checkout Blocked", f"{e}\nPlease commit or stash changes first.") + except GitCommandError as e: + self.logger.error(f"Failed checkout branch '{selected_branch}': {e}", exc_info=True) + status_final = f"Error checking out branch: {type(e).__name__}" self.main_frame.show_error("Error", f"Checkout failed:\n{e}") except Exception as e: - self.logger.exception(f"Unexpected checkout error: {e}") + self.logger.exception(f"Unexpected checkout branch error: {e}") + status_final = "Error: Unexpected checkout failure." self.main_frame.show_error("Error", f"Unexpected:\n{e}") + finally: + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + # --- History Method (Synchronous) --- def refresh_commit_history(self): """Refreshes the commit history display (Synchronous).""" self.logger.info("--- Action Triggered: Refresh History ---") + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar("Processing: Refreshing history...") + svn_path = self._get_and_validate_svn_path("Refresh History") log_data = [] # Default + status_final = "Ready." + branch_filter = None # Per il messaggio di stato + if svn_path and self._is_repo_ready(svn_path): - branch_filter = None + # Ottieni filtro da GUI if hasattr(self.main_frame, "history_branch_filter_var"): selected_filter = self.main_frame.history_branch_filter_var.get() if selected_filter and selected_filter != "-- All History --": branch_filter = selected_filter - self.logger.debug( - f"Refreshing history with filter: {branch_filter or 'All'}" - ) + log_scope = f"'{branch_filter}'" if branch_filter else "All History" + self.logger.debug(f"Refreshing history for filter: {log_scope}") + try: + # Ottieni log da GitCommands log_data = self.git_commands.get_commit_log( svn_path, max_count=200, branch=branch_filter ) - self.logger.info(f"History refreshed ({len(log_data)} entries).") + count = len(log_data) + self.logger.info(f"History refreshed ({count} entries for {log_scope}).") + status_final = f"History refreshed ({count} entries for {log_scope})." except Exception as e: self.logger.error(f"Failed refresh history: {e}", exc_info=True) + status_final = "Error refreshing history." self.main_frame.show_error("Error", f"Could not refresh history:\n{e}") + log_data = ["(Error loading history)"] else: self.logger.debug("Refresh History skipped: Repository not ready.") + status_final = "Ready (Repository not ready)." + log_data = ["(Repository not ready)"] + # Aggiorna GUI if hasattr(self.main_frame, "update_history_display"): self.main_frame.update_history_display(log_data) + if hasattr(self, 'main_frame'): self.main_frame.update_status_bar(status_final) + # --- GUI State Utilities --- def _clear_and_disable_fields(self): """Clears input fields and disables widgets when no valid profile/repo.""" - # (Mantenere versione precedente robusta) if hasattr(self, "main_frame") and self.main_frame.winfo_exists(): mf = self.main_frame - self.logger.debug("Clearing/disabling GUI fields.") - if hasattr(mf, "svn_path_entry"): - mf.svn_path_entry.delete(0, tk.END) - if hasattr(mf, "usb_path_entry"): - mf.usb_path_entry.delete(0, tk.END) - if hasattr(mf, "bundle_name_entry"): - mf.bundle_name_entry.delete(0, tk.END) - if hasattr(mf, "bundle_updated_name_entry"): - mf.bundle_updated_name_entry.delete(0, tk.END) - if hasattr(mf, "autobackup_var"): - mf.autobackup_var.set(False) - if hasattr(mf, "backup_dir_var"): - mf.backup_dir_var.set("") - mf.toggle_backup_dir() - if hasattr(mf, "autocommit_var"): - mf.autocommit_var.set(False) - if hasattr(mf, "clear_commit_message"): - mf.clear_commit_message() - if hasattr(mf, "update_tag_list"): - mf.update_tag_list([]) - if hasattr(mf, "update_branch_list"): - mf.update_branch_list([], None) - if hasattr(mf, "update_history_display"): - mf.update_history_display([]) - if hasattr(mf, "update_history_branch_filter"): - mf.update_history_branch_filter([]) - self.update_svn_status_indicator("") # This disables most buttons - if hasattr(mf, "remove_profile_button"): - mf.remove_profile_button.config(state=tk.DISABLED) - if hasattr(mf, "save_settings_button"): - mf.save_settings_button.config(state=tk.DISABLED) + self.logger.debug("Clearing/disabling GUI fields due to invalid profile/repo.") + # Pulisci campi testo + if hasattr(mf, "svn_path_entry"): mf.svn_path_entry.delete(0, tk.END) + if hasattr(mf, "usb_path_entry"): mf.usb_path_entry.delete(0, tk.END) + if hasattr(mf, "bundle_name_entry"): mf.bundle_name_entry.delete(0, tk.END) + if hasattr(mf, "bundle_updated_name_entry"): mf.bundle_updated_name_entry.delete(0, tk.END) + if hasattr(mf, "backup_dir_var"): mf.backup_dir_var.set("") + if hasattr(mf, "backup_exclude_extensions_var"): mf.backup_exclude_extensions_var.set("") + if hasattr(mf, "backup_exclude_dirs_var"): mf.backup_exclude_dirs_var.set("") + if hasattr(mf, "clear_commit_message"): mf.clear_commit_message() + + # Pulisci variabili booleane + if hasattr(mf, "autobackup_var"): mf.autobackup_var.set(False) + if hasattr(mf, "autocommit_var"): mf.autocommit_var.set(False) + + # Aggiorna stato widget backup dir + if hasattr(mf, "toggle_backup_dir"): mf.toggle_backup_dir() + + # Pulisci liste + if hasattr(mf, "update_tag_list"): mf.update_tag_list([]) + if hasattr(mf, "update_branch_list"): mf.update_branch_list([], None) + if hasattr(mf, "update_history_display"): mf.update_history_display([]) + if hasattr(mf, "update_history_branch_filter"): mf.update_history_branch_filter([]) + if hasattr(mf, "update_changed_files_list"): mf.update_changed_files_list([]) + + # Disabilita la maggior parte dei controlli chiamando update_svn_status_indicator + # con un percorso vuoto (che risulterà in is_repo_ready=False) + self.update_svn_status_indicator("") + + # Disabilita anche i pulsanti specifici del profilo + if hasattr(mf, "remove_profile_button"): mf.remove_profile_button.config(state=tk.DISABLED) + if hasattr(mf, "save_settings_button"): mf.save_settings_button.config(state=tk.DISABLED) + + # Aggiorna status bar + self.main_frame.update_status_bar("No profile selected or repository not ready.") + def show_fatal_error(self, message): - """Shows a fatal error message box.""" - # (Mantenere versione precedente robusta) + """Shows a fatal error message box and attempts to close gracefully.""" self.logger.critical(f"FATAL ERROR: {message}") try: - parent = ( - self.master - if hasattr(self, "master") - and self.master - and self.master.winfo_exists() - else None - ) + # Prova a mostrare il messaggio usando il master Toplevel se esiste + parent = None + if hasattr(self, "master") and self.master and self.master.winfo_exists(): + parent = self.master messagebox.showerror("Fatal Error", message, parent=parent) except Exception as e: + # Fallback se anche la messagebox fallisce (es. Tk non inizializzato) print(f"FATAL ERROR (GUI message failed: {e}): {message}") + finally: + # Tenta di distruggere la finestra master in ogni caso + if hasattr(self, "master") and self.master and self.master.winfo_exists(): + try: + self.master.destroy() + except: pass # Ignora errori durante la distruzione # --- Application Entry Point --- @@ -1367,29 +1835,38 @@ def main(): level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" ) logger = logging.getLogger("main") # Logger specifico per main - root = None # Inizializza a None + root = None # Inizializza a None per gestione errori app = None try: + logger.debug("Creating Tkinter root window...") root = tk.Tk() - # Imposta dimensioni minime per assicurare visibilità di tutti gli elementi - # Regola questi valori se necessario in base al layout finale - root.minsize(750, 700) # Altezza leggermente ridotta rispetto a prima + # Imposta dimensioni minime per assicurare visibilità elementi base + root.minsize(800, 700) # Aumentato leggermente per nuova lista file logger.info("Tkinter root window created.") - app = GitSvnSyncApp(root) # Crea l'applicazione principale - # Controlla se l'app è stata inizializzata correttamente prima di avviare mainloop - if hasattr(app, "main_frame") and app.main_frame: + + # Crea l'applicazione principale + logger.debug("Initializing GitSvnSyncApp...") + app = GitSvnSyncApp(root) + logger.debug("GitSvnSyncApp initialization attempt complete.") + + # Controlla se l'app (e la GUI) è stata inizializzata correttamente + # prima di avviare mainloop. Necessario se __init__ può fallire e ritornare. + if hasattr(app, "main_frame") and app.main_frame and app.main_frame.winfo_exists(): logger.info("Starting Tkinter main event loop.") root.mainloop() logger.info("Tkinter main event loop finished.") else: logger.critical( - "Application initialization failed before mainloop could start." + "Application initialization failed before mainloop could start. Exiting." ) + # Se root esiste ancora (errore dopo creazione root ma prima di mainloop), distruggila. if root and root.winfo_exists(): - root.destroy() # Pulisci finestra se esiste + root.destroy() + except Exception as e: logger.exception("Fatal error during application startup or main loop.") - try: # Tenta di mostrare un errore finale all'utente + # Tenta di mostrare un errore finale all'utente, ma potrebbe fallire + try: parent = root if root and root.winfo_exists() else None messagebox.showerror( "Fatal Application Error", @@ -1398,9 +1875,10 @@ def main(): ) except Exception as msg_e: print(f"FATAL ERROR (GUI error reporting failed: {msg_e}):\n{e}") + # Stampa su console come ultima risorsa finally: logging.info("Application exiting.") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/diff_viewer.py b/diff_viewer.py new file mode 100644 index 0000000..611e794 --- /dev/null +++ b/diff_viewer.py @@ -0,0 +1,538 @@ +# --- START OF FILE diff_viewer.py --- + +import tkinter as tk +from tkinter import ttk, scrolledtext, Canvas, messagebox +import difflib +import logging +import os +import locale # Per fallback encoding + +class DiffViewerWindow(tk.Toplevel): + """ + Toplevel window to display a side-by-side diff of a file. + Shows differences between the working directory version and the HEAD version. + Includes synchronized scrolling via clicking on the overview minimap. + Relies solely on minimap clicks for vertical navigation. + """ + def __init__(self, master, logger, git_commands, repo_path, file_status_line): + """ + Initializes the Diff Viewer window. + + Args: + master: The parent widget (usually the main Tkinter root). + logger: Logger instance for logging messages. + git_commands: Instance of GitCommands for interacting with Git. + repo_path (str): Absolute path to the Git repository. + file_status_line (str): The full status line from 'git status --short' + for the file to be diffed (e.g., " M path/to/file.py"). + """ + super().__init__(master) + + # Setup Logger (con fallback) + if not isinstance(logger, (logging.Logger, logging.LoggerAdapter)): + print("ERROR: DiffViewerWindow requires a valid logger. Using fallback.") + self.logger = logging.getLogger("DiffViewer_Fallback") + self.logger.setLevel(logging.WARNING) # Logga solo warning/error + else: + self.logger = logger + + # Setup GitCommands + if git_commands is None: + self.logger.critical("GitCommands instance is required.") + raise ValueError("DiffViewerWindow requires a valid GitCommands instance.") + self.git_commands = git_commands + + self.repo_path = repo_path + + # Pulisci e valida il percorso del file dalla riga di stato + self.relative_file_path = self._clean_relative_path(file_status_line) + if not self.relative_file_path: + self.logger.error(f"Cannot show diff: Invalid path from '{file_status_line}'.") + messagebox.showerror("Error", "Invalid file path for diff.", parent=master) + self.after_idle(self.destroy) # Chiudi se percorso non valido + return # Esce dall'init + + # Configurazione Finestra Toplevel + self.title(f"Diff - {os.path.basename(self.relative_file_path)}") + self.geometry("920x650") # Dimensioni finestra + self.minsize(550, 400) # Dimensioni minime + self.grab_set() # Rendi modale + self.transient(master) # Appare sopra il parent + + # Stato interno + self.head_content_lines = [] + self.workdir_content_lines = [] + self.diff_map = [] # Mappa per minimappa: 0=uguale, 1=rimosso/mod, 2=aggiunto/mod + self._scrolling_active = False # Flag per prevenire eventi ricorsivi + + # Costruisci l'interfaccia grafica + self._create_widgets() + + # Carica il contenuto dei file e calcola/mostra il diff + load_ok = False + try: + load_ok = self._load_content() + if load_ok: + self._compute_and_display_diff() + self._setup_scrolling() # Configura solo il click sulla minimappa + else: + # Se _load_content fallisce, mostra errore nei widget + self.logger.warning("Content loading failed, populating text widgets with error messages.") + self._populate_text(self.text_head, [""]) + self._populate_text(self.text_workdir, [""]) + # Disegna comunque la minimappa (sarà vuota) + self.minimap_canvas.after(50, self._draw_minimap) + + except Exception as load_err: + # Errore imprevisto durante il caricamento/diff + self.logger.exception(f"Unexpected error during diff setup for '{self.relative_file_path}': {load_err}") + messagebox.showerror("Fatal Error", f"Failed to display diff:\n{load_err}", parent=self) + self.after_idle(self.destroy) # Chiudi in caso di errore grave + return # Esce dall'init + + # Centra la finestra rispetto al parent + self._center_window(master) + + + def _clean_relative_path(self, file_status_line): + """ + Extracts a clean relative path from a git status line. + Handles status codes, spaces, renames, and quotes. + """ + try: + # Pulisci caratteri speciali e spazi esterni + line = file_status_line.strip('\x00').strip() + if not line: + self.logger.warning("Received empty status line for cleaning.") + return "" + + # Trova il primo spazio per separare lo stato dal percorso + space_index = -1 + for i, char in enumerate(line): + if char.isspace(): + space_index = i + break + + # Se non c'è spazio o è alla fine, il formato è sospetto + if space_index == -1 or space_index + 1 >= len(line): + # Se la linea non contiene spazi, potremmo assumere che sia solo il nome file? + # Questo potrebbe accadere se lo stato viene perso prima. Per sicurezza, + # richiediamo uno spazio per separare stato e percorso. + self.logger.warning(f"Could not find valid space separator in status line: '{line}'") + return "" # Ritorna vuoto se non c'è separatore valido + + # Estrai la parte del percorso dopo il primo spazio + relative_path_raw = line[space_index + 1:].strip() + + # Gestisci rinominati nel formato "XY orig -> new" + if "->" in relative_path_raw: + # Prendi solo il nome del file *nuovo* (dopo ->) + relative_path = relative_path_raw.split("->")[-1].strip() + else: + relative_path = relative_path_raw + + # Gestisci le quote se presenti (tipico se non si usa -z) + if len(relative_path) >= 2 and relative_path.startswith('"') and relative_path.endswith('"'): + relative_path = relative_path[1:-1] + + # Controlla se il risultato è vuoto dopo la pulizia + if not relative_path: + self.logger.warning(f"Extracted path is empty from status line: '{line}'") + return "" + + self.logger.debug(f"Cleaned path from '{file_status_line}' -> '{relative_path}'") + return relative_path + + except Exception as e: + self.logger.error(f"Error cleaning path from status line '{file_status_line}': {e}", exc_info=True) + return "" # Ritorna vuoto in caso di errore + + + def _center_window(self, parent): + """Centers the Toplevel window relative to its parent.""" + try: + self.update_idletasks() # Assicura dimensioni aggiornate + # Calcola le coordinate + parent_x = parent.winfo_rootx() + parent_y = parent.winfo_rooty() + parent_width = parent.winfo_width() + parent_height = parent.winfo_height() + window_width = self.winfo_width() + window_height = self.winfo_height() + x = parent_x + (parent_width // 2) - (window_width // 2) + y = parent_y + (parent_height // 2) - (window_height // 2) + # Assicura visibilità sullo schermo + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + x = max(0, min(x, screen_width - window_width)) + y = max(0, min(y, screen_height - window_height)) + # Imposta geometria + self.geometry(f"+{int(x)}+{int(y)}") + except Exception as e: + self.logger.error(f"Could not center DiffViewerWindow: {e}", exc_info=True) + + + def _create_widgets(self): + """Creates the main widgets for the diff view (NO main scrollbar).""" + main_frame = ttk.Frame(self, padding=5) + main_frame.pack(fill=tk.BOTH, expand=True) + main_frame.rowconfigure(1, weight=1) # Riga dei text widget si espande + # Colonne: Sinistra (HEAD), Destra (Workdir), Minimap + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + main_frame.columnconfigure(2, weight=0, minsize=40) # Minimap larghezza fissa + + # Etichette Titoli + ttk.Label(main_frame, text=f"HEAD (Repository Version)").grid( + row=0, column=0, sticky='w', padx=(5,2), pady=(0, 2)) + ttk.Label(main_frame, text=f"Working Directory Version").grid( + row=0, column=1, sticky='w', padx=(2,5), pady=(0, 2)) + ttk.Label(main_frame, text="Overview").grid( + row=0, column=2, sticky='w', padx=(5,0), pady=(0, 2)) + + # Widget Testo (senza scrollbar individuali o yscrollcommand) + text_font = ("Consolas", 10) # Font Monospace + common_text_opts = { + "wrap": tk.NONE, # No a capo + "font": text_font, + "padx": 5, "pady": 5, + "undo": False, # Non serve undo in visualizzazione + "state": tk.DISABLED, # Inizia non modificabile + "borderwidth": 1, + "relief": tk.SUNKEN + } + self.text_head = tk.Text(main_frame, **common_text_opts) + self.text_workdir = tk.Text(main_frame, **common_text_opts) + + self.text_head.grid(row=1, column=0, sticky='nsew', padx=(5, 2)) + self.text_workdir.grid(row=1, column=1, sticky='nsew', padx=(2, 5)) + + # Minimap Canvas + self.minimap_canvas = Canvas( + main_frame, width=40, bg='#F0F0F0', + relief=tk.SUNKEN, borderwidth=1, highlightthickness=0 + ) + self.minimap_canvas.grid(row=1, column=2, sticky='ns', padx=(5, 0)) + self.minimap_canvas.bind("", self._on_minimap_resize) + + # Configurazione Tag per Highlighting + self.text_head.tag_config("removed", background="#FFE0E0") # Rosso chiaro + self.text_head.tag_config("empty", background="#F5F5F5", foreground="#A0A0A0") # Grigio per filler + self.text_workdir.tag_config("added", background="#E0FFE0") # Verde chiaro + self.text_workdir.tag_config("empty", background="#F5F5F5", foreground="#A0A0A0") # Grigio per filler + + + def _load_content(self): + """Loads file content from HEAD and working directory.""" + self.logger.info(f"Loading content for diff: {self.relative_file_path}") + load_success = True + # Carica HEAD + try: + head_content_raw = self.git_commands.get_file_content_from_ref( + self.repo_path, self.relative_file_path, ref="HEAD" + ) + self.head_content_lines = head_content_raw.splitlines() if head_content_raw is not None else [] + if head_content_raw is None: + self.logger.warning(f"File '{self.relative_file_path}' not found in HEAD (likely new or deleted in HEAD).") + except Exception as e_head: + self.logger.error(f"Error loading HEAD content for '{self.relative_file_path}': {e_head}", exc_info=True) + self.head_content_lines = [f""] + load_success = False + + # Carica Working Directory + try: + workdir_full_path = os.path.join(self.repo_path, self.relative_file_path) + if os.path.exists(workdir_full_path) and os.path.isfile(workdir_full_path): + try: + with open(workdir_full_path, 'r', encoding='utf-8') as f: + self.workdir_content_lines = f.read().splitlines() + except UnicodeDecodeError: + self.logger.warning(f"UTF-8 decode failed for WD {workdir_full_path}, trying fallback.") + try: + fallback_encoding = locale.getpreferredencoding(False) or 'latin-1' + with open(workdir_full_path, 'r', encoding=fallback_encoding, errors='replace') as f: + self.workdir_content_lines = f.read().splitlines() + except Exception as fb_e: + self.logger.error(f"WD fallback read failed: {fb_e}") + self.workdir_content_lines = [f""] + load_success = False + else: + self.logger.info(f"Working directory file not found: {workdir_full_path}") + self.workdir_content_lines = [] # File non esiste sul disco + except Exception as e_wd: + self.logger.error(f"Error loading Workdir content for '{self.relative_file_path}': {e_wd}", exc_info=True) + self.workdir_content_lines = [f""] + load_success = False + + return load_success + + + def _compute_and_display_diff(self): + """Calculates the diff and populates the text widgets with highlights, + ensuring consistent line count for display using SequenceMatcher.""" + self.logger.debug("Computing and displaying diff...") + if not self.head_content_lines and not self.workdir_content_lines: + self.logger.warning("Both HEAD and Workdir content are empty or failed.") + self._populate_text(self.text_head, ["(No content in HEAD)"]) + self._populate_text(self.text_workdir, ["(No content in Workdir)"]) + self.diff_map = [] + self.minimap_canvas.after(50, self._draw_minimap) + return + + matcher = difflib.SequenceMatcher(None, self.head_content_lines, self.workdir_content_lines, autojunk=False) + head_lines_display = [] + workdir_lines_display = [] + diff_map_for_minimap = [] + + self.logger.debug("Generating aligned display lines using SequenceMatcher opcodes...") + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + head_chunk = self.head_content_lines[i1:i2] + workdir_chunk = self.workdir_content_lines[j1:j2] + + if tag == 'equal': + for k in range(len(head_chunk)): + head_lines_display.append((' ', head_chunk[k])) + workdir_lines_display.append((' ', workdir_chunk[k])) + diff_map_for_minimap.append(0) # 0 = equal + elif tag == 'delete': + for k in range(len(head_chunk)): + head_lines_display.append(('-', head_chunk[k])) + workdir_lines_display.append(('empty', '')) + diff_map_for_minimap.append(1) # 1 = deleted/modified head + elif tag == 'insert': + for k in range(len(workdir_chunk)): + head_lines_display.append(('empty', '')) + workdir_lines_display.append(('+', workdir_chunk[k])) + diff_map_for_minimap.append(2) # 2 = added/modified workdir + elif tag == 'replace': + len_head = len(head_chunk) + len_workdir = len(workdir_chunk) + max_len = max(len_head, len_workdir) + for k in range(max_len): + head_line = head_chunk[k] if k < len_head else '' + workdir_line = workdir_chunk[k] if k < len_workdir else '' + head_code = '-' if k < len_head else 'empty' + workdir_code = '+' if k < len_workdir else 'empty' + head_lines_display.append((head_code, head_line)) + workdir_lines_display.append((workdir_code, workdir_line)) + # Segna come modificato (1 per rosso, 2 per verde) + diff_map_for_minimap.append(1 if head_code != 'empty' else 2) + + # Verifica coerenza (opzionale ma utile per debug) + if len(head_lines_display) != len(workdir_lines_display) or \ + len(head_lines_display) != len(diff_map_for_minimap): + self.logger.error("Internal Diff Error: Mismatch in aligned line counts!") + else: + self.logger.debug(f"Aligned display generated with {len(head_lines_display)} lines.") + + self.diff_map = diff_map_for_minimap # Aggiorna mappa per minimappa + + # Popola widget testo + self._populate_text(self.text_head, head_lines_display) + self._populate_text(self.text_workdir, workdir_lines_display) + + # Disegna minimappa (con ritardo) + self.minimap_canvas.after(100, self._draw_minimap) + + + def _populate_text(self, text_widget, lines_data): + """Populates a text widget with lines and applies tags.""" + widget_name = text_widget.winfo_name() + self.logger.debug(f"Populating widget {widget_name} with {len(lines_data)} lines.") + text_widget.config(state=tk.NORMAL) # Abilita per modifica + text_widget.delete('1.0', tk.END) # Pulisci contenuto precedente + + if not lines_data: + self.logger.debug(f"No lines data for {widget_name}.") + text_widget.insert('1.0', "(No content)\n", ("empty",)) # Mostra messaggio vuoto + else: + content_string = "" + tags_to_apply = [] # Lista di (tag_name, start_index, end_index) + current_line_num = 1 # Tkinter usa indice 1-based + for data_tuple in lines_data: + if not isinstance(data_tuple, tuple) or len(data_tuple) != 2: + self.logger.warning(f"Skipping malformed data in {widget_name}: {data_tuple}") + line_content = "\n" + code = 'error' # Codice fittizio per evitare errori tag + else: + code, content = data_tuple + line_content = content + '\n' # Aggiungi newline per Text widget + + # Aggiungi il contenuto della riga alla stringa completa + content_string += line_content + + # Calcola indici per i tag + start_index = f"{current_line_num}.0" + end_index = f"{current_line_num}.end" # .end si riferisce alla fine della linea logica + + # Aggiungi tag alla lista se necessario + if code == '-': tags_to_apply.append(("removed", start_index, end_index)) + elif code == '+': tags_to_apply.append(("added", start_index, end_index)) + elif code == 'empty': tags_to_apply.append(("empty", start_index, end_index)) + + current_line_num += 1 # Incrementa numero linea per la prossima iterazione + + # Inserisci tutto il testo in una volta sola + text_widget.insert('1.0', content_string) + + # Applica tutti i tag raccolti + for tag, start, end in tags_to_apply: + try: + text_widget.tag_add(tag, start, end) + except tk.TclError as tag_err: + self.logger.error(f"Error applying tag '{tag}' from {start} to {end}: {tag_err}") + + text_widget.config(state=tk.DISABLED) # Disabilita dopo aver popolato + text_widget.yview_moveto(0.0) # Assicura che sia all'inizio + + + def _draw_minimap(self): + """Draws the minimap overview based on self.diff_map.""" + canvas = self.minimap_canvas + canvas.delete("diff_line") + canvas.delete("viewport_indicator") + + num_lines = len(self.diff_map) + if num_lines == 0: + self.logger.debug("No diff map data for minimap.") + return + + canvas.update_idletasks() + canvas_width = canvas.winfo_width() + canvas_height = canvas.winfo_height() + + if canvas_width <= 1 or canvas_height <= 1: + self.logger.debug(f"Skipping minimap draw due to invalid dimensions: {canvas_width}x{canvas_height}") + return + + self.logger.debug(f"Drawing minimap ({canvas_width}x{canvas_height}) for {num_lines} lines.") + + # Calcola l'altezza ESATTA per linea, senza max(1.0) inizialmente + # per vedere se il problema è l'arrotondamento + exact_line_height = float(canvas_height) / num_lines if num_lines > 0 else 1.0 + + # --- MODIFICA: Arrotondamento e gestione bordo inferiore --- + accumulated_height = 0.0 + for i, diff_type in enumerate(self.diff_map): + y_start = round(accumulated_height) # Arrotonda inizio + + # Calcola l'altezza *teorica* per questa linea + current_line_height = exact_line_height + accumulated_height += current_line_height + y_end = round(accumulated_height) # Arrotonda fine + + # Assicura che y_end sia almeno y_start + 1 per visibilità + if y_end <= y_start: y_end = y_start + 1 + + # Gestione ultima linea per riempire esattamente il canvas + if i == num_lines - 1: + y_end = canvas_height # Forza l'ultima linea ad arrivare al bordo + + # Colore (come modificato prima per avere rosso per tutte le diff) + color = '#F0F0F0' # Grigio + if diff_type == 1 or diff_type == 2: + color = '#F8D0D0' # Rosso + + canvas.create_rectangle(0, y_start, canvas_width, y_end, + fill=color, outline="", tags="diff_line") + # --- FINE MODIFICA --- + + self._update_minimap_viewport() + + + # --- Scrolling Logic (Solo Minimap Click) --- + + def _setup_scrolling(self): + """ Configure ONLY minimap click for navigation. """ + self.minimap_canvas.bind("", self._on_minimap_click) + # Nessun altro binding necessario (no scrollbar, no yscrollcommand) + + def _reset_scroll_flag(self): + """Resets the scrolling flag.""" + self._scrolling_active = False + + def _update_minimap_viewport(self): + """ Updates the indicator rectangle on the minimap. """ + canvas = self.minimap_canvas + canvas.delete("viewport_indicator") # Rimuovi il vecchio + try: + # Leggi la posizione della vista da uno dei widget testo + first, last = self.text_head.yview() + except tk.TclError: + self.logger.warning("TclError getting text view, cannot update minimap viewport.") + return # Esce se il widget è distrutto + + # Assicurati che le dimensioni siano valide + canvas.update_idletasks() + canvas_height = canvas.winfo_height() + canvas_width = canvas.winfo_width() + if canvas_height <= 1 or canvas_width <=1 : + self.logger.debug("Canvas not ready for viewport update.") + return + + # Calcola coordinate y del rettangolo indicatore + y_start = first * canvas_height + y_end = last * canvas_height + # Assicura altezza minima di 1 pixel + if y_end <= y_start : y_end = y_start + 1 + + # Disegna il nuovo indicatore + canvas.create_rectangle( + 1, y_start, canvas_width - 1, y_end, # Margine laterale 1px + outline='black', width=1, + tags="viewport_indicator" + ) + # Assicurati che sia sopra le linee colorate + canvas.tag_raise("viewport_indicator") + + + def _on_minimap_click(self, event): + """ Handles clicks on the minimap to scroll the text views. """ + if self._scrolling_active: return # Previene chiamate multiple + self._scrolling_active = True + try: + canvas = self.minimap_canvas + canvas_height = canvas.winfo_height() + if canvas_height <= 1: return # Canvas non pronto + + # Calcola la frazione verticale del click + target_fraction = event.y / canvas_height + # Limita la frazione tra 0.0 e poco meno di 1.0 per evitare problemi + target_fraction = max(0.0, min(target_fraction, 1.0)) + + self.logger.debug(f"Minimap clicked at y={event.y}, fraction={target_fraction:.3f}") + + # Muovi entrambi i widget di testo a quella frazione + # yview_moveto imposta la *prima* linea visibile a quella frazione + self.text_head.yview_moveto(target_fraction) + self.text_workdir.yview_moveto(target_fraction) + + # Aggiorna l'indicatore sulla minimappa per riflettere la nuova vista + self._update_minimap_viewport() + + finally: + # Resetta il flag dopo un breve ritardo per evitare loop + self.after(50, self._reset_scroll_flag) # Ritardo leggermente aumentato + + # Usiamo un debounce semplice per evitare ridisegni troppo frequenti durante il drag + _configure_timer_id = None + + def _on_minimap_resize(self, event): + """Called when the minimap canvas is resized (e.g., window resize). + Schedules a redraw after a short delay (debounce).""" + # Cancella il timer precedente se esiste + if self._configure_timer_id: + self.after_cancel(self._configure_timer_id) + + # Pianifica il ridisegno dopo un breve ritardo (es. 100ms) + # Questo evita di ridisegnare continuamente mentre l'utente trascina il bordo + self._configure_timer_id = self.after(100, self._redraw_minimap_on_resize) + + def _redraw_minimap_on_resize(self): + """Actually redraws the minimap. Called by the debounced timer.""" + self.logger.debug("Redrawing minimap due to resize event.") + self._configure_timer_id = None # Resetta ID timer + # Chiama la funzione di disegno esistente + self._draw_minimap() + +# --- END OF FILE diff_viewer.py --- \ No newline at end of file diff --git a/git_commands.py b/git_commands.py index 50740c1..729b7c2 100644 --- a/git_commands.py +++ b/git_commands.py @@ -1100,4 +1100,81 @@ class GitCommands: raise except Exception as e: self.logger.exception(f"Unexpected error in get_matching_gitignore_rule: {e}") - raise GitCommandError(f"Unexpected check-ignore -v error: {e}", command=command) from e \ No newline at end of file + raise GitCommandError(f"Unexpected check-ignore -v error: {e}", command=command) from e + + def get_status_short(self, working_directory): + """ + Gets the repository status in short format (-z for null termination). + + Returns: + list: A list of strings, each representing a changed file + and its status (e.g., " M filename", "?? newfile"). + Returns empty list on error. + """ + self.logger.debug(f"Getting short status for '{working_directory}'") + # -z null terminates filenames, safer for paths with spaces/special chars + # --ignored=no : Non mostrare file ignorati (di solito default, ma esplicito) + command = ["git", "status", "--short", "-z", "--ignored=no"] + try: + # L'output di status può essere utile, ma non enorme. Logghiamolo a DEBUG. + result = self.log_and_execute(command, working_directory, check=True, log_output_level=logging.DEBUG) + # Split by null terminator and filter out empty strings + status_lines = [line for line in result.stdout.split('\0') if line] + self.logger.info(f"Git status check returned {len(status_lines)} changed/untracked items.") + return status_lines + except (GitCommandError, ValueError) as e: + self.logger.error(f"Failed to get git status: {e}") + return [] # Ritorna lista vuota in caso di errore + except Exception as e: + self.logger.exception(f"Unexpected error getting git status: {e}") + return [] + + def get_file_content_from_ref(self, working_directory, file_path, ref="HEAD"): + """ + Retrieves the content of a file from a specific Git reference (e.g., HEAD, index). + + Args: + working_directory (str): Path to the Git repository. + file_path (str): Relative path to the file within the repo. + ref (str): The Git reference (e.g., "HEAD", "master", commit hash, or ":" for index). + + Returns: + str or None: The file content as a string, or None if the file + doesn't exist in that ref or an error occurs. + """ + # Normalizza i separatori per Git (usa sempre '/') + git_style_path = file_path.replace(os.path.sep, '/') + ref_prefix = f"{ref}:" if ref else ":" # Usa ":" per l'index se ref è vuoto o None + + # Costruisci l'argomento ref:path + ref_path_arg = f"{ref_prefix}{git_style_path}" + + self.logger.debug(f"Getting file content for '{ref_path_arg}' in '{working_directory}'") + command = ["git", "show", ref_path_arg] + + try: + # check=False perché un file non trovato in HEAD/index non è un errore fatale, + # significa solo che è nuovo o cancellato. L'output va a DEBUG. + result = self.log_and_execute(command, working_directory, check=False, log_output_level=logging.DEBUG) + + if result.returncode == 0: + # Successo, ritorna il contenuto (stdout) + return result.stdout + elif result.returncode == 128 and ("exists on disk, but not in" in result.stderr or \ + "does not exist in" in result.stderr or \ + "did not match any file" in result.stderr): + # Codice 128 con errore specifico -> file non trovato nel ref + self.logger.debug(f"File '{git_style_path}' not found in ref '{ref}'.") + return None # Ritorna None per indicare file non esistente nel ref + else: + # Altro errore di git show + self.logger.error(f"git show command failed for '{ref_path_arg}' with code {result.returncode}. Stderr: {result.stderr}") + return None # Ritorna None per indicare errore generico + + except (GitCommandError, ValueError) as e: + # Questo catturerebbe errori nell'esecuzione del comando stesso (raro se check=False) + self.logger.error(f"Error executing git show for '{ref_path_arg}': {e}") + return None + except Exception as e: + self.logger.exception(f"Unexpected error in get_file_content_from_ref for '{ref_path_arg}': {e}") + return None \ No newline at end of file diff --git a/gui.py b/gui.py index 03cd4e0..1ed73c6 100644 --- a/gui.py +++ b/gui.py @@ -381,6 +381,8 @@ class MainFrame(ttk.Frame): refresh_branches_cb, checkout_branch_cb, create_branch_cb, + refresh_changed_files_cb, # <-- NUOVO PARAMETRO + open_diff_viewer_cb, ): """Initializes the MainFrame with tabs.""" super().__init__(master) @@ -405,6 +407,8 @@ class MainFrame(ttk.Frame): self.refresh_branches_callback = refresh_branches_cb self.checkout_branch_callback = checkout_branch_cb self.create_branch_callback = create_branch_cb + self.open_diff_viewer_callback = open_diff_viewer_cb + # Store instances and initial data self.config_manager = config_manager_instance @@ -793,56 +797,88 @@ class MainFrame(ttk.Frame): return frame def _create_commit_tab(self): - """Creates the frame for the 'Commit' tab.""" - # (No changes needed here for this modification) + """Creates the frame for the 'Commit' tab with changed files list.""" frame = ttk.Frame(self.notebook, padding=(10, 10)) - frame.rowconfigure(2, weight=1) - frame.columnconfigure(0, weight=1) + # Riduci peso riga messaggio, aumenta peso riga lista file + frame.rowconfigure(2, weight=0) # Riga messaggio commit non si espande molto + frame.rowconfigure(4, weight=1) # Riga lista file si espande + frame.columnconfigure(0, weight=1) # Colonna principale si espande + + # --- Sezione Autocommit --- (Invariata) self.autocommit_checkbox = ttk.Checkbutton( - frame, + frame, # <<< DEVE ESSERE 'frame' QUI text="Enable Autocommit before 'Create Bundle' action", variable=self.autocommit_var, state=tk.DISABLED, ) - self.autocommit_checkbox.grid( - row=0, column=0, columnspan=2, sticky="w", padx=5, pady=(0, 10) - ) - self.create_tooltip( - self.autocommit_checkbox, - "If checked, use message below to commit before Create Bundle.", - ) - ttk.Label(frame, text="Commit Message:").grid( - row=1, column=0, columnspan=2, sticky="w", padx=5 - ) + self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, sticky="w", padx=5, pady=(0, 5)) + self.create_tooltip(self.autocommit_checkbox, "...") + + # --- Sezione Messaggio Commit --- (Altezza ridotta) + ttk.Label(frame, text="Commit Message:").grid(row=1, column=0, columnspan=3, sticky="w", padx=5) self.commit_message_text = scrolledtext.ScrolledText( frame, - height=7, - width=60, - wrap=tk.WORD, - font=("Segoe UI", 9), - state=tk.DISABLED, - undo=True, - padx=5, - pady=5, + height=3, # <<< Altezza ridotta + width=60, wrap=tk.WORD, font=("Segoe UI", 9), + state=tk.DISABLED, undo=True, padx=5, pady=5, ) - self.commit_message_text.grid( - row=2, column=0, columnspan=2, sticky="nsew", padx=5, pady=(0, 5) + self.commit_message_text.grid(row=2, column=0, columnspan=3, sticky="ew", padx=5, pady=(0, 5)) + self.create_tooltip(self.commit_message_text, "Enter commit message...") + + # --- MODIFICA: Aggiunta Sezione Changed Files --- + changes_frame = ttk.LabelFrame(frame, text="Changes to be Committed / Staged", padding=(10, 5)) + changes_frame.grid(row=3, column=0, columnspan=3, sticky='nsew', padx=5, pady=(5,5)) + changes_frame.rowconfigure(0, weight=1) # Lista si espande + changes_frame.columnconfigure(0, weight=1) # Lista si espande + + # Lista File Modificati + list_sub_frame = ttk.Frame(changes_frame) # Frame per listbox e scrollbar + list_sub_frame.grid(row=0, column=0, sticky='nsew', pady=(0, 5)) + list_sub_frame.rowconfigure(0, weight=1) + list_sub_frame.columnconfigure(0, weight=1) + + self.changed_files_listbox = tk.Listbox( + list_sub_frame, + height=8, # Altezza iniziale, ma si espanderà con la riga 4 di 'frame' + exportselection=False, + selectmode=tk.SINGLE, + font=("Consolas", 9), # Font Monospace utile per status ) - self.create_tooltip( - self.commit_message_text, "Enter commit message for manual or autocommit." + self.changed_files_listbox.grid(row=0, column=0, sticky="nsew") + # Associa doppio click all'apertura del diff viewer (verrà collegato in GitUtility) + self.changed_files_listbox.bind("", self._on_changed_file_double_click) + + scrollbar_list = ttk.Scrollbar( + list_sub_frame, orient=tk.VERTICAL, command=self.changed_files_listbox.yview ) + scrollbar_list.grid(row=0, column=1, sticky="ns") + self.changed_files_listbox.config(yscrollcommand=scrollbar_list.set) + self.create_tooltip(self.changed_files_listbox, "Double-click a file to view changes (diff).") + + # Pulsante Refresh Lista File + self.refresh_changes_button = ttk.Button( + changes_frame, + text="Refresh List", + # Collegato a callback in GitUtility + # command=self.refresh_changed_files_callback + state=tk.DISABLED # Abilitato quando repo è pronto + ) + self.refresh_changes_button.grid(row=1, column=0, sticky="w", padx=(0, 5), pady=(5, 0)) + self.create_tooltip(self.refresh_changes_button, "Refresh the list of changed files.") + # --- FINE MODIFICA --- + + + # --- Pulsante Commit Manuale --- (Spostato sotto) self.commit_button = ttk.Button( - frame, + frame, # Ora nel frame principale text="Commit All Changes Manually", - command=self.commit_changes_callback, + # command=self.commit_changes_callback state=tk.DISABLED, ) - self.commit_button.grid( - row=3, column=0, columnspan=2, sticky="e", padx=5, pady=5 - ) - self.create_tooltip( - self.commit_button, "Stage ALL changes and commit with the message above." - ) + # Messo in basso a destra + self.commit_button.grid(row=4, column=2, sticky="se", padx=5, pady=5) + self.create_tooltip(self.commit_button, "Stage ALL changes and commit with the message above.") + return frame def _create_tags_tab(self): @@ -1306,3 +1342,27 @@ class MainFrame(ttk.Frame): def update_tooltip(self, widget, text): self.create_tooltip(widget, text) # Recreate is simplest + + def update_changed_files_list(self, files_status_list): + """Clears and populates the changed files listbox.""" + if not hasattr(self, "changed_files_listbox"): return + self.changed_files_listbox.config(state=tk.NORMAL) + self.changed_files_listbox.delete(0, tk.END) + if files_status_list: + # Potresti voler formattare meglio lo stato qui + for status_line in files_status_list: + self.changed_files_listbox.insert(tk.END, status_line) + else: + self.changed_files_listbox.insert(tk.END, "(No changes detected)") + + # Questo chiamerà la funzione vera in GitUtility + def _on_changed_file_double_click(self, event): + # Recupera l'indice selezionato dalla listbox + widget = event.widget + selection = widget.curselection() + if selection: + index = selection[0] + file_status_line = widget.get(index) + # Chiama il metodo del controller (verrà impostato in __init__ di MainFrame) + if hasattr(self, 'open_diff_viewer_callback') and callable(self.open_diff_viewer_callback): + self.open_diff_viewer_callback(file_status_line)