diff --git a/GitUtility.py b/GitUtility.py index 4a6d92b..b398fda 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -568,7 +568,8 @@ class GitSvnSyncApp: # Load remote repository settings if hasattr(mf, "remote_url_var") and hasattr(mf, "remote_name_var"): - mf.remote_url_var.set(settings.get("remote_url", "")) + remote_url_loaded = settings.get("remote_url", "") # Ottieni l'URL + mf.remote_url_var.set(remote_url_loaded) mf.remote_name_var.set( settings.get("remote_name", DEFAULT_REMOTE_NAME) ) @@ -599,10 +600,26 @@ class GitSvnSyncApp: self.refresh_branch_list() # Refreshes local branches self.refresh_commit_history() self.refresh_changed_files_list() - # Also check remote status after loading a ready profile - self.check_connection_auth() # Check auth/conn status - self.refresh_remote_status() # Check ahead/behind status - # Status bar will be updated by the results of these async operations + if remote_url_loaded: # Controlla se l'URL caricato NON è vuoto + log_handler.log_debug("Remote URL found, initiating connection check.", func_name=func_name) + self.check_connection_auth() # Check auth/conn status + self.refresh_remote_status() # Check ahead/behind status (richiede upstream valido) + else: + # Se l'URL è vuoto, non tentare la connessione + log_handler.log_info("Remote URL is empty. Skipping connection check and remote status update.", func_name=func_name) + # Imposta lo stato auth/sync come sconosciuto/non configurato + self._update_gui_auth_status("unknown") # O un nuovo stato 'not_configured'? 'unknown' va bene per ora. + if hasattr(self.main_frame, "update_ahead_behind_status"): + self.main_frame.update_ahead_behind_status( + status_text="Sync Status: (Remote not configured)" + ) + # Assicurati che il bottone refresh sync sia disabilitato + if hasattr(self.main_frame, "refresh_sync_status_button"): + self.main_frame.refresh_sync_status_button.config(state=tk.DISABLED) + # Aggiorna la status bar + mf.update_status_bar( + f"Profile '{profile_name}' loaded (Remote not configured)." + ) else: # If not ready, clear dynamic GUI lists log_handler.log_info( @@ -2883,14 +2900,16 @@ class GitSvnSyncApp: } ) - def view_commit_details(self, history_line: str): + def view_commit_details(self, history_line_or_hash: str): # Nome parametro aggiornabile per chiarezza """ - Callback triggered by double-clicking a line in the history view. - Extracts the commit hash and starts an async worker to fetch details. + Callback triggered by clicking a line in the history view (now Treeview). + Extracts the commit hash (if needed, ma ora riceve solo l'hash) + and starts an async worker to fetch details. """ func_name: str = "view_commit_details" + commit_hash_short = history_line_or_hash.strip() log_handler.log_info( - f"--- Action Triggered: View Commit Details for line: '{history_line}' ---", + f"--- Action Triggered: View Commit Details for hash: '{commit_hash_short}' ---", func_name=func_name ) @@ -2907,30 +2926,12 @@ class GitSvnSyncApp: ) return - # --- Estrarre l'Hash del Commit dalla Riga di Log --- - # Assumiamo che il formato (%h) sia all'inizio della riga, seguito da spazio - commit_hash_short: Optional[str] = None - try: - parts: List[str] = history_line.split(maxsplit=1) # Divide solo al primo spazio - if parts and len(parts[0]) > 0: # Assumendo che l'hash sia la prima parte - commit_hash_short = parts[0] - # Potremmo validare che sia un hash valido (es. 7+ caratteri esadecimali) - if not re.match(r"^[0-9a-fA-F]{7,}$", commit_hash_short): - raise ValueError(f"Extracted part '{commit_hash_short}' doesn't look like a commit hash.") - else: - raise ValueError("Could not split history line to find hash.") - - log_handler.log_debug(f"Extracted commit hash: {commit_hash_short}", func_name=func_name) - - except Exception as e: - log_handler.log_error( - f"Could not extract commit hash from history line '{history_line}': {e}", - func_name=func_name - ) - self.main_frame.show_error( - "Parsing Error", f"Could not identify commit hash in selected line:\n{history_line}" - ) - return + # --- Validazione Hash (Opzionale ma consigliata) --- + if not commit_hash_short or not re.match(r"^[0-9a-fA-F]{7,}$", commit_hash_short): + log_handler.log_error(f"Invalid commit hash received: '{commit_hash_short}'", func_name=func_name) + self.main_frame.show_error("Input Error", f"Invalid commit hash format:\n{commit_hash_short}") + return + # --- Fine Validazione --- # --- Start Async Worker to Get Commit Details --- log_handler.log_info( @@ -2940,7 +2941,7 @@ class GitSvnSyncApp: args: tuple = (self.git_commands, svn_path, commit_hash_short) # Start the async operation self._start_async_operation( - worker_func=async_workers.run_get_commit_details_async, # NUOVO WORKER + worker_func=async_workers.run_get_commit_details_async, # Worker corretto args_tuple=args, context_dict={ "context": "get_commit_details", diff --git a/async_workers.py b/async_workers.py index adf04a8..13a430f 100644 --- a/async_workers.py +++ b/async_workers.py @@ -4,6 +4,7 @@ import os import queue import logging # Usato solo per i livelli, non per loggare direttamente import datetime # Necessario per alcuni messaggi +from typing import Tuple, Dict, List, Callable, Optional, Any # Importa i moduli necessari per la logica interna e le dipendenze import log_handler @@ -1823,7 +1824,7 @@ def run_get_commit_details_async( if show_result.returncode == 0 and show_result.stdout: # --- Parsing dell'Output --- # L'output sarà: Metadati formattati\n\nLista file separati da NUL\0 - output_parts: List[str] = show_result.stdout.split('\n\n', 1) + output_parts: List[str] = show_result.stdout.split('\n', 1) metadata_line: str = output_parts[0] files_part_raw: str = output_parts[1] if len(output_parts) > 1 else "" @@ -1852,31 +1853,47 @@ def run_get_commit_details_async( parsed_files: List[Tuple[str, str, Optional[str]]] = [] i: int = 0 while i < len(file_entries): - # Ogni voce file dovrebbe avere status e path - # Rinominati/Copiati (R/C) hanno status, old_path, new_path + # Ottieni lo stato (primo elemento della sequenza per un file) + if not file_entries[i]: # Salta stringhe vuote risultanti da split (dovuto a \x00 finali?) + i += 1 + continue status_char: str = file_entries[i].strip() + if status_char.startswith(('R', 'C')): # Renamed/Copied - if i + 2 < len(file_entries): - # Rimuovi score (es. R100 -> R) - status_code = status_char[0] + # Necessita di status, old_path, new_path (3 elementi) + if i + 2 < len(file_entries): # <-- CONTROLLO INDICE + status_code = status_char[0] # Prendi solo R o C old_path = file_entries[i+1] new_path = file_entries[i+2] - parsed_files.append((status_code, old_path, new_path)) - i += 3 # Avanza di 3 elementi + # Verifica che i path non siano vuoti (ulteriore sicurezza) + if old_path and new_path: + parsed_files.append((status_code, old_path, new_path)) + else: + log_handler.log_warning(f"[Worker] Incomplete R/C entry (empty path?): {file_entries[i:i+3]}", func_name=func_name) + i += 3 # Avanza di 3 else: - log_handler.log_warning(f"[Worker] Incomplete R/C entry: {file_entries[i:]}", func_name=func_name) - break # Esce dal loop se i dati sono incompleti + # Dati incompleti per R/C + log_handler.log_warning(f"[Worker] Incomplete R/C entry (not enough parts): {file_entries[i:]}", func_name=func_name) + break # Interrompi il loop se la struttura dati è corrotta elif status_char: # Added, Modified, Deleted, Type Changed - if i + 1 < len(file_entries): - file_path = file_entries[i+1] - parsed_files.append((status_char[0], file_path, None)) # None per new_path - i += 2 # Avanza di 2 elementi + # Necessita di status, file_path (2 elementi) + if i + 1 < len(file_entries): # <-- CONTROLLO INDICE + file_path = file_entries[i+1] + # Verifica che il path non sia vuoto + if file_path: + # Status: prendi solo il primo carattere (es. M, A, D, T) + parsed_files.append((status_char[0], file_path, None)) # Usa None per new_path + else: + log_handler.log_warning(f"[Worker] Incomplete A/M/D/T entry (empty path?): {file_entries[i:i+2]}", func_name=func_name) + i += 2 # Avanza di 2 else: - log_handler.log_warning(f"[Worker] Incomplete A/M/D/T entry: {file_entries[i:]}", func_name=func_name) - break + # Dati incompleti per A/M/D/T + log_handler.log_warning(f"[Worker] Incomplete A/M/D/T entry (not enough parts): {file_entries[i:]}", func_name=func_name) + break # Interrompi loop else: - # Ignora elementi vuoti (potrebbero esserci NUL consecutivi?) - i += 1 + # Questo caso non dovrebbe verificarsi con strip+split corretti, ma per sicurezza + log_handler.log_warning(f"[Worker] Unexpected empty status_char at index {i}", func_name=func_name) + i += 1 # Avanza comunque per evitare loop infinito commit_details['files_changed'] = parsed_files log_handler.log_debug( f"[Worker] Parsed {len(parsed_files)} changed files.", func_name=func_name diff --git a/diff_viewer.py b/diff_viewer.py index 54772f9..4b83adc 100644 --- a/diff_viewer.py +++ b/diff_viewer.py @@ -9,7 +9,7 @@ import log_handler from git_commands import GitCommands, GitCommandError # Importa anche i livelli di logging se usati nei messaggi fallback import logging -from typing import List # Per type hints +from typing import List, Optional # Per type hints # --- Tooltip Class Definition (Copiata da gui.py per standalone, o importata) --- # (Assumiamo sia definita qui o importata correttamente se messa in un file separato) @@ -186,6 +186,7 @@ class DiffViewerWindow(tk.Toplevel): self.diff_map: List[int] = [] # Mappa per minimap self._scrolling_active: bool = False # Flag per scroll sincronizzato self._configure_timer_id = None # ID per debounce resize minimap + self.loading_label: Optional[ttk.Label] = None # --- Costruzione Interfaccia Grafica --- log_handler.log_debug("Creating diff viewer widgets...", func_name=func_name) @@ -195,6 +196,10 @@ class DiffViewerWindow(tk.Toplevel): log_handler.log_debug("Loading content and computing diff...", func_name=func_name) load_ok = False try: + self._show_loading_message() + # Forza l'aggiornamento della GUI per mostrare il messaggio prima del blocco + self.update_idletasks() + load_ok = self._load_content() # Usa self.ref1 e self.ref2 internamente if load_ok: # Se il caricamento (anche parziale, es. file non trovato) è ok, calcola diff @@ -208,9 +213,12 @@ class DiffViewerWindow(tk.Toplevel): self._populate_text(self.text_pane2, [("error", f"")]) # Disegna comunque minimappa vuota self.minimap_canvas.after(50, self._draw_minimap) + + self._hide_loading_message() except Exception as load_err: # Errore imprevisto durante caricamento o calcolo diff + self._hide_loading_message() log_handler.log_exception(f"Unexpected error during diff setup for '{self.relative_file_path}': {load_err}", func_name=func_name) messagebox.showerror("Fatal Error", f"Failed to display diff:\n{load_err}", parent=self) # Chiudi la finestra se l'inizializzazione fallisce gravemente @@ -275,6 +283,17 @@ class DiffViewerWindow(tk.Toplevel): self.minimap_canvas.grid(row=1, column=2, sticky="ns", padx=(5, 0)) # Ridisegna su resize self.minimap_canvas.bind("", self._on_minimap_resize) + + self.loading_label = ttk.Label( + main_frame, # Appoggia al main_frame per essere sopra gli altri + text="Loading file versions, please wait...", # Testo in inglese + font=("Segoe UI", 12, "italic"), + background="#FFFACD", # Giallo chiaro + foreground="#555555", # Grigio scuro + relief=tk.SOLID, + borderwidth=1, + padding=(10, 5) + ) # Configurazione Tag Highlighting (aggiornato nomi widget) self.text_pane1.tag_config("removed", background="#FFE0E0") # Righe solo a sinistra @@ -559,6 +578,27 @@ class DiffViewerWindow(tk.Toplevel): # Disegna indicatore viewport self._update_minimap_viewport() log_handler.log_debug("Minimap drawing complete.", func_name=func_name) + + def _hide_loading_message(self): + """Removes the loading label from view.""" + if self.loading_label and self.loading_label.winfo_exists(): + try: + self.loading_label.place_forget() + log_handler.log_debug("Loading message hidden.", func_name="_hide_loading_message") + except Exception as e: + log_handler.log_error(f"Failed to hide loading label: {e}", func_name="_hide_loading_message") + + def _show_loading_message(self): + """Places and lifts the loading label to make it visible.""" + if self.loading_label and self.loading_label.winfo_exists(): + try: + # Place al centro del main_frame (primo figlio di self) + container = self.winfo_children()[0] if self.winfo_children() else self + self.loading_label.place(in_=container, relx=0.5, rely=0.5, anchor=tk.CENTER) + self.loading_label.lift() + log_handler.log_debug("Loading message shown.", func_name="_show_loading_message") + except Exception as e: + log_handler.log_error(f"Failed to place/lift loading label: {e}", func_name="_show_loading_message") # --- Scrolling Logic --- diff --git a/git_commands.py b/git_commands.py index 47916bd..0da00a8 100644 --- a/git_commands.py +++ b/git_commands.py @@ -62,10 +62,8 @@ class GitCommands: working_directory: str, check: bool = True, log_output_level: int = logging.INFO, - # ---<<< NUOVI PARAMETRI >>>--- capture: bool = True, # Cattura stdout/stderr? hide_console: bool = True, # Nascondi finestra console (Windows)? - # ---<<< FINE NUOVI PARAMETRI >>>--- ) -> subprocess.CompletedProcess: """ Executes a shell command, logs details, handles errors, with options @@ -115,7 +113,7 @@ class GitCommands: # --- Esecuzione Comando --- try: - # ---<<< MODIFICA: Configurazione startupinfo/flags >>>--- + # ---Configurazione startupinfo/flags --- startupinfo = None creationflags = 0 # Applica solo se richiesto E siamo su Windows @@ -130,7 +128,6 @@ class GitCommands: # per isolare l'input/output del comando interattivo creationflags = subprocess.CREATE_NEW_CONSOLE # Su Linux/macOS, non impostiamo nulla di speciale per la finestra - # ---<<< FINE MODIFICA >>>--- # Timeout diagnostico (mantenuto) timeout_seconds = 60 # Aumentato leggermente per operazioni remote @@ -157,18 +154,13 @@ class GitCommands: # --- Log Output di Successo (solo se catturato) --- if capture and (result.returncode == 0 or not check): - stdout_log_debug = ( - result.stdout.strip() if result.stdout else "" - ) - stderr_log_debug = ( - result.stderr.strip() if result.stderr else "" - ) - # Logga sempre a DEBUG + stdout_repr = repr(result.stdout) if result.stdout else "" + stderr_repr = repr(result.stderr) if result.stderr else "" log_handler.log_debug( - f"Command successful (RC={result.returncode}). Output:\n" - f"--- stdout ---\n{stdout_log_debug}\n" - f"--- stderr ---\n{stderr_log_debug}\n" - f"--- End Output ---", + f"Command successful (RC={result.returncode}). RAW Output:\n" + f"--- stdout (repr) ---\n{stdout_repr}\n" + f"--- stderr (repr) ---\n{stderr_repr}\n" + f"--- End RAW Output ---", func_name=func_name, ) # Logga anche al livello richiesto se diverso da DEBUG @@ -184,17 +176,13 @@ class GitCommands: # --- Gestione Errori (modificata per CalledProcessError quando capture=False) --- except subprocess.TimeoutExpired as e: # (Gestione Timeout invariata) - log_handler.log_error( - f"Command timed out after {timeout_seconds}s: {command_str}", - func_name=func_name, + stderr_repr = repr(e.stderr) if capture and e.stderr else "" + stdout_repr = repr(e.stdout) if capture and e.stdout else "" + err_msg = ( + f"Command failed (RC {e.returncode}) in '{effective_cwd}'.\n" + f"CMD: {command_str}\nRAW_STDERR: {stderr_repr}\nRAW_STDOUT: {stdout_repr}" ) - stderr_out = ( - e.stderr.strip() if capture and e.stderr else "" - ) - stdout_out = ( - e.stdout.strip() if capture and e.stdout else "" - ) - log_handler.log_error(f"Timeout stderr: {stderr_out}", func_name=func_name) + log_handler.log_error(err_msg, func_name=func_name) raise GitCommandError( f"Timeout after {timeout_seconds}s.", command=safe_command_parts, diff --git a/gui.py b/gui.py index 4e7a68f..e56a458 100644 --- a/gui.py +++ b/gui.py @@ -499,6 +499,7 @@ class MainFrame(ttk.Frame): self.delete_local_branch_callback = delete_local_branch_cb self.merge_local_branch_callback = merge_local_branch_cb self.compare_branch_with_current_callback = compare_branch_with_current_cb + self.view_commit_details_callback = view_commit_details_cb self.config_manager = config_manager_instance self.initial_profile_sections = profile_sections_list @@ -539,7 +540,8 @@ class MainFrame(ttk.Frame): self.tags_tab_frame = self._create_tags_tab() self.branch_tab_frame = self._create_branch_tab() # Crea la listbox/button originale self.remote_tab_frame = self._create_remote_tab() # Crea la nuova tab con le liste affiancate - self.history_tab_frame = self._create_history_tab() + #self.history_tab_frame = self._create_history_tab() + self.history_tab_frame = self._create_history_tab_treeview() # Aggiunta delle tab al Notebook (ordine di visualizzazione) self.notebook.add(self.repo_tab_frame, text=" Repository / Bundle ") @@ -751,6 +753,102 @@ class MainFrame(ttk.Frame): self.create_tooltip(self.refresh_local_branches_button_remote_tab, "Update the list of local branches.") return frame + + def _create_history_tab_treeview(self): + """Creates the widgets for the History tab using ttk.Treeview.""" + frame = ttk.Frame(self.notebook, padding=(10, 10)) + # Configura righe/colonne per espansione + frame.rowconfigure(1, weight=1) # Riga per Treeview si espande + frame.columnconfigure(0, weight=1) # Colonna per Treeview si espande + + # Frame per controlli (filtro, refresh) + controls_frame = ttk.Frame(frame) + controls_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + controls_frame.columnconfigure(1, weight=1) # Combobox si espande + + ttk.Label(controls_frame, text="Filter History by Branch/Tag:").pack(side=tk.LEFT, padx=(0, 5)) + self.history_branch_filter_var = tk.StringVar() + self.history_branch_filter_combo = ttk.Combobox( + controls_frame, + textvariable=self.history_branch_filter_var, + state="readonly", # Inizia readonly, diventa disabled se repo non pronto + width=40, + ) + self.history_branch_filter_combo.pack( + side=tk.LEFT, expand=True, fill=tk.X, padx=5 + ) + # Assicurati che il callback esista nel controller + if hasattr(self, 'refresh_history_callback') and callable(self.refresh_history_callback): + self.history_branch_filter_combo.bind( + "<>", lambda e: self.refresh_history_callback() + ) + self.create_tooltip( + self.history_branch_filter_combo, "Select a branch or tag to filter the commit history." + ) + + self.refresh_history_button = ttk.Button( + controls_frame, + text="Refresh History", + state=tk.DISABLED, # Inizia disabilitato + ) + # Assicurati che il callback esista nel controller + if hasattr(self, 'refresh_history_callback') and callable(self.refresh_history_callback): + self.refresh_history_button.config(command=self.refresh_history_callback) + self.refresh_history_button.pack(side=tk.LEFT, padx=5) + self.create_tooltip(self.refresh_history_button, "Reload commit history based on the selected filter.") + + # Frame per Treeview e Scrollbars + content_frame = ttk.Frame(frame) + content_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + content_frame.rowconfigure(0, weight=1) # Riga Treeview si espande + content_frame.columnconfigure(0, weight=1) # Colonna Treeview si espande + + # Definisci le colonne per la Treeview + # ('hash', 'datetime', 'author', 'details') <-- 'details' conterrà soggetto e refs + columns = ('hash', 'datetime', 'author', 'details') + self.history_tree = ttk.Treeview( + content_frame, + columns=columns, + show='headings', # Mostra solo le intestazioni, non la colonna #0 + selectmode='browse', # Seleziona solo una riga alla volta + height=15 # Altezza suggerita in righe + ) + + # Definisci Intestazioni (headings) + self.history_tree.heading('hash', text='Hash', anchor='w') + self.history_tree.heading('datetime', text='Date/Time', anchor='w') + self.history_tree.heading('author', text='Author', anchor='w') + self.history_tree.heading('details', text='Subject / Refs', anchor='w') # Titolo colonna aggiornato + + # Definisci Colonne (larghezza e ancoraggio) + self.history_tree.column('hash', width=80, stretch=tk.NO, anchor='w') + self.history_tree.column('datetime', width=140, stretch=tk.NO, anchor='w') + self.history_tree.column('author', width=150, stretch=tk.NO, anchor='w') + self.history_tree.column('details', width=450, stretch=tk.YES, anchor='w') # Questa colonna si espande + + # Scrollbars Verticale e Orizzontale + tree_scrollbar_y = ttk.Scrollbar( + content_frame, orient=tk.VERTICAL, command=self.history_tree.yview + ) + tree_scrollbar_x = ttk.Scrollbar( + content_frame, orient=tk.HORIZONTAL, command=self.history_tree.xview + ) + self.history_tree.configure( + yscrollcommand=tree_scrollbar_y.set, + xscrollcommand=tree_scrollbar_x.set + ) + + # Layout Treeview e Scrollbars usando grid nel content_frame + self.history_tree.grid(row=0, column=0, sticky="nsew") + tree_scrollbar_y.grid(row=0, column=1, sticky="ns") + tree_scrollbar_x.grid(row=1, column=0, columnspan=2, sticky="ew") # Span su 2 colonne + + # Binding Doppio Click (chiama nuovo handler _on_history_double_click_tree) + # Assicurati che il metodo _on_history_double_click_tree sia definito più avanti + self.history_tree.bind("", self._on_history_double_click_tree) + self.create_tooltip(self.history_tree, "Double-click a commit line to view details.") + + return frame def _show_remote_branches_context_menu(self, event): """Displays the context menu for the remote branches listbox.""" @@ -1487,7 +1585,7 @@ class MainFrame(ttk.Frame): ) self.history_text.grid(row=2, column=0, sticky="nsew", padx=5, pady=(0, 5)) - self.history_text.bind("", self._on_history_double_click) + self.history_text.bind("", self._on_history_double_click) self.create_tooltip(self.history_text, "Double-click a commit to view details.") history_xscroll = ttk.Scrollbar( @@ -1733,13 +1831,20 @@ class MainFrame(ttk.Frame): if not hasattr(self, "view_commit_details_callback") or \ not callable(self.view_commit_details_callback): return + + widget_state = self.history_text.cget("state") try: + + if widget_state == tk.DISABLED: + self.history_text.config(state=tk.NORMAL) + # Get the index of the clicked character index = self.history_text.index(f"@{event.x},{event.y}") # Get the content of the line containing the index line_content: str = self.history_text.get(f"{index} linestart", f"{index} lineend") line_content = line_content.strip() # Remove leading/trailing whitespace + log_handler.log_debug(f"History clicked. Line content: '{line_content}'", func_name="_on_history_double_click") # Pass the extracted line content to the callback function if line_content and not line_content.startswith("("): # Avoid acting on "(Error..)" lines @@ -1750,78 +1855,150 @@ class MainFrame(ttk.Frame): except Exception as e: # Log unexpected errors (consider using log_handler if available) print(f"Error handling history double-click: {e}", file=sys.stderr) + finally: + # ---<<< MODIFICA: Ripristina stato originale >>>--- + # Always restore the original state if it was changed + if widget_state == tk.DISABLED and self.history_text.winfo_exists(): + try: + self.history_text.config(state=tk.DISABLED) + except Exception: # Ignore errors restoring state + pass + + def _on_history_double_click_tree(self, event: tk.Event) -> None: + """ + Handles the double-click event on the history Treeview. + Extracts the commit hash from the selected item and calls the view_commit_details callback. + """ + func_name = "_on_history_double_click_tree" + tree = getattr(self, "history_tree", None) + view_cb = getattr(self, "view_commit_details_callback", None) - def update_history_display(self, log_lines): - """Clears and populates the history ScrolledText widget.""" - func_name = "update_history_display (GUI)" # Nome specifico per i log - # ---<<< INIZIO MODIFICA DEBUG & ERRORE >>>--- - log_handler.log_debug( - f"Received log_lines type={type(log_lines)}, count={len(log_lines) if isinstance(log_lines, list) else 'N/A'}. " - f"Sample: {repr(log_lines[:5]) if isinstance(log_lines, list) else repr(log_lines)}", - func_name=func_name, - ) - history_widget = getattr(self, "history_text", None) - if not history_widget or not history_widget.winfo_exists(): - log_handler.log_error( - "history_text widget not available for update.", func_name=func_name + # ---<<< AGGIUNTA: Logging Dettagliato per Debug >>>--- + tree_exists = tree is not None and tree.winfo_exists() + cb_callable = callable(view_cb) + log_handler.log_debug(f"Tree exists: {tree_exists}. Callback callable: {cb_callable}.", func_name=func_name) + if view_cb and not cb_callable: + log_handler.log_warning(f"view_commit_details_callback is NOT callable. Type: {type(view_cb)}, Value: {repr(view_cb)}", func_name=func_name) + # ---<<< FINE AGGIUNTA >>>--- + + # Verifica che la treeview e il callback esistano E siano validi + if not tree_exists or not cb_callable: # Condizione aggiornata per usare le variabili debug + # Il messaggio di warning esistente va bene + log_handler.log_warning( + "History tree or view commit details callback not configured or available.", + func_name=func_name ) + return # Esce dalla funzione + + # Se siamo qui, sia tree che view_cb sono validi + try: + selected_iid = tree.focus() + if not selected_iid: + log_handler.log_debug("No item selected in history tree on double click.", func_name=func_name) + return + + item_data = tree.item(selected_iid) + item_values = item_data.get("values") + + if item_values and len(item_values) > 0: + commit_hash_short = str(item_values[0]).strip() + if commit_hash_short and not commit_hash_short.startswith("("): + log_handler.log_info(f"History tree double-clicked. Requesting details for hash: '{commit_hash_short}'", func_name=func_name) + view_cb(commit_hash_short) # Chiama il callback validato + else: + log_handler.log_debug(f"Ignoring double-click on placeholder/invalid history item: {item_values}", func_name=func_name) + else: + log_handler.log_warning(f"Could not get values for selected history item IID: {selected_iid}", func_name=func_name) + + except Exception as e: + log_handler.log_exception(f"Error handling history tree double-click: {e}", func_name=func_name) + messagebox.showerror("Error", f"Could not process history selection:\n{e}", parent=self) + + def update_history_display(self, log_lines: List[str]): + """Clears and populates the history Treeview widget.""" + func_name = "update_history_display_(Tree)" # Identificatore per i log + log_handler.log_debug(f"Updating history display (Treeview) with {len(log_lines) if isinstance(log_lines, list) else 'N/A'} lines.", func_name=func_name) + + tree = getattr(self, "history_tree", None) + if not tree or not tree.winfo_exists(): + log_handler.log_error("history_tree widget not available for update.", func_name=func_name) return try: - history_widget.config(state=tk.NORMAL) - history_widget.delete("1.0", tk.END) + # Pulisci Treeview precedente + for item_id in tree.get_children(): + tree.delete(item_id) - # Assicurati che log_lines sia una lista prima di fare join + # Processa le righe di log ricevute if isinstance(log_lines, list): if log_lines: - # Resetta colore (se era rosso o altro) - try: - # Potrebbe non esserci un colore foreground specifico per ScrolledText nello stile - # Potremmo impostare a nero o lasciare il default del widget - default_fg = "black" # Assumiamo nero come default sicuro - if history_widget.cget("fg") != default_fg: - history_widget.config(fg=default_fg) - except tk.TclError: - pass + # Processa ogni riga e inseriscila nella Treeview + for i, line in enumerate(log_lines): + line_str = str(line).strip() + # Ignora placeholder/errori inseriti precedentemente + if not line_str or line_str.startswith("("): + continue - # Unisci le linee (assicurati siano stringhe) - text_to_insert = "\n".join(map(str, log_lines)) - history_widget.insert(tk.END, text_to_insert) - else: # Lista vuota valida - history_widget.insert(tk.END, "(No history found)") - history_widget.config(fg="grey") # Colore grigio per indicare vuoto - else: # log_lines non è una lista (errore?) - log_handler.log_warning( - f"Invalid data received for history: {repr(log_lines)}", - func_name=func_name, - ) - history_widget.insert( - tk.END, f"(Invalid data received: {repr(log_lines)})" - ) - history_widget.config(fg="orange") # Arancione per dato inatteso + # --- Parsing della riga di log --- + # Formato atteso: "hash datetime | author | (refs) subject" + commit_hash, commit_datetime, commit_author, commit_details = "", "", "", line_str # Fallback + try: # Aggiungi try/except per il parsing robusto + parts = line_str.split('|', 2) # Divide in max 3 parti + if len(parts) >= 3: # Formato completo atteso + part1 = parts[0].strip(); part2 = parts[1].strip(); part3 = parts[2].strip() + first_space = part1.find(' ') + if first_space != -1: + commit_hash = part1[:first_space] + # Prendi solo la data e ora (primi 16 caratteri) + commit_datetime = part1[first_space:].strip()[:16] + else: # Caso strano: solo hash? + commit_hash = part1 + commit_datetime = "N/A" # O lascia vuoto + commit_author = part2 + commit_details = part3 # Contiene soggetto e refs + elif len(parts) == 2: # Manca autore? (meno probabile con il formato usato) + part1 = parts[0].strip(); part3 = parts[1].strip() + first_space = part1.find(' ') + if first_space != -1: + commit_hash = part1[:first_space]; commit_datetime = part1[first_space:].strip()[:16] + else: commit_hash = part1; commit_datetime = "N/A" + commit_author = "N/A" # Autore mancante + commit_details = part3 + elif len(parts) == 1: # Formato irriconoscibile, mostra come details + commit_details = line_str # Mostra l'intera riga come dettagli + commit_hash = "N/A"; commit_datetime = "N/A"; commit_author = "N/A" + except Exception as parse_err: + log_handler.log_warning(f"Could not parse history line '{line_str}': {parse_err}", func_name=func_name) + # Imposta valori di fallback per mostrare qualcosa + commit_hash = "Error"; commit_datetime = ""; commit_author="" + commit_details = line_str # Mostra riga originale - history_widget.config(state=tk.DISABLED) # Rendi read-only - history_widget.yview_moveto(0.0) - history_widget.xview_moveto(0.0) + # Inserisci la riga parsata nella Treeview + tree.insert( + parent='', index=tk.END, iid=i, # Usa indice riga come IID + values=(commit_hash, commit_datetime, commit_author, commit_details) + ) + + else: # Lista vuota valida + # Inserisci un messaggio placeholder + tree.insert(parent='', index=tk.END, iid=0, values=("", "", "", "(No history found)")) + + else: # Dati non validi (non è una lista) + log_handler.log_warning(f"Invalid data received for history (Treeview): {repr(log_lines)}", func_name=func_name) + tree.insert(parent='', index=tk.END, iid=0, values=("", "", "", "(Error: Invalid data received)")) + + # Scroll all'inizio dopo aver popolato + tree.yview_moveto(0.0) + tree.xview_moveto(0.0) except Exception as e: - log_handler.log_exception( - f"Error updating history GUI: {e}", func_name=func_name - ) - # Fallback: Mostra errore nel widget di testo - try: - if history_widget.winfo_exists(): - history_widget.config(state=tk.NORMAL) - history_widget.delete("1.0", tk.END) - history_widget.insert(tk.END, "(Error displaying history)") - history_widget.config( - state=tk.DISABLED, fg="red" - ) # Rosso e disabilitato + log_handler.log_exception(f"Error updating history Treeview GUI: {e}", func_name=func_name) + try: # Fallback di visualizzazione errore nella treeview + if tree.winfo_exists(): + for item_id in tree.get_children(): tree.delete(item_id) + tree.insert(parent='', index=tk.END, iid=0, values=("", "", "", "(Error displaying history)")) except Exception as fallback_e: - log_handler.log_error( - f"Error displaying fallback error in history widget: {fallback_e}", - func_name=func_name, - ) + log_handler.log_error(f"Error displaying fallback error in history Treeview: {fallback_e}", func_name=func_name) # ---<<< FINE MODIFICA DEBUG & ERRORE >>>--- def update_history_branch_filter(self, branches_tags, current_ref=None): @@ -2140,16 +2317,36 @@ class MainFrame(ttk.Frame): except Exception as e: log_handler.log_error(f"Error setting state for profile dropdown: {e}", func_name="set_action_widgets_state") # Gestione stato liste (devono essere selezionabili se abilitate) + # Le liste devono essere utilizzabili (NORMAL) quando le azioni sono abilitate, + # e visivamente indicate come disabilitate altrimenti (potremmo cambiare colore o stile?) + # Per ora, impostiamo lo stato NORMAL/DISABLED dove applicabile. + # Per Treeview, controlliamo i binding. list_state = tk.NORMAL if state == tk.NORMAL else tk.DISABLED - listboxes = [ + interactive_lists = [ # Lista di widget lista interattivi getattr(self, name, None) for name in - ["tag_listbox", "branch_listbox", "history_text", "changed_files_listbox", + ["tag_listbox", "branch_listbox", "changed_files_listbox", "remote_branches_listbox", "local_branches_listbox_remote_tab"] ] - for lb in listboxes: - if lb and lb.winfo_exists(): + for lb in interactive_lists: + if lb and lb.winfo_exists() and isinstance(lb, tk.Listbox): # Controlla tipo try: lb.config(state=list_state) - except Exception: pass # Ignora errori specifici widget (es. Text non ha 'readonly') + except Exception: pass # Ignora errori + + # Gestione binding per History Treeview + history_tree = getattr(self, "history_tree", None) + if history_tree and history_tree.winfo_exists(): + try: + if state == tk.DISABLED: + # Unbind - Rimuovi il binding per doppio click + history_tree.unbind("") + else: + # Rebind - Riaggiungi il binding se non già presente + # Controllare se il binding esiste già potrebbe essere complesso, + # riassociare è generalmente sicuro. + if hasattr(self, "_on_history_double_click_tree"): + history_tree.bind("", self._on_history_double_click_tree) + except Exception as e: + log_handler.log_error(f"Error configuring history tree bindings: {e}", func_name="set_action_widgets_state") def update_ahead_behind_status( self, diff --git a/profile_handler.py b/profile_handler.py deleted file mode 100644 index 13391b4..0000000 --- a/profile_handler.py +++ /dev/null @@ -1,284 +0,0 @@ -# --- START OF FILE profile_handler.py --- - -# profile_handler.py -# Rimosso import logging - -# Importa il nuovo gestore della coda log -import log_handler - -# Importa ConfigManager e costanti associate -from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR - - -class ProfileHandler: - """ - Handles loading, saving, adding, removing profiles via ConfigManager. - Uses log_handler for logging. - (Nota: Alcune logiche qui potrebbero essere duplicate o gestite - direttamente in GitUtility.py. Questo modulo potrebbe necessitare - di revisione o rimozione se non più centrale.) - """ - - # Rimosso logger da __init__ - def __init__(self, config_manager: ConfigManager): - """ - Initializes the ProfileHandler. - - Args: - config_manager (ConfigManager): Instance to interact with config file. - """ - # Validazione tipo input - if not isinstance(config_manager, ConfigManager): - # Log critico e solleva errore se config_manager non è valido - msg = "ProfileHandler requires a valid ConfigManager instance." - # Usa log_handler per errore critico (se già configurato altrove) - # o print/raise direttamente - print(f"CRITICAL ERROR: {msg}") - # log_handler.log_critical(msg, func_name="__init__") # Potrebbe non funzionare qui - raise TypeError(msg) - - self.config_manager = config_manager - log_handler.log_debug("ProfileHandler initialized.", func_name="__init__") - - def get_profile_list(self): - """Returns the list of available profile names.""" - # Delega direttamente a config_manager - return self.config_manager.get_profile_sections() - - def load_profile_data(self, profile_name): - """ - Loads all relevant data for a given profile name. - - Args: - profile_name (str): The name of the profile to load. - - Returns: - dict or None: A dictionary containing all profile settings, - or None if the profile doesn't exist. - """ - func_name = "load_profile_data" - log_handler.log_info( - f"Loading data for profile: '{profile_name}'", func_name=func_name - ) - # Verifica esistenza profilo tramite config_manager - if profile_name not in self.config_manager.get_profile_sections(): - log_handler.log_error( - f"Cannot load: Profile '{profile_name}' not found.", func_name=func_name - ) - return None - - # Ottieni chiavi e default da config_manager (fonte unica di verità) - keys_with_defaults = self.config_manager._get_expected_keys_with_defaults() - profile_data = {} - - # Carica ogni opzione usando ConfigManager - log_handler.log_debug( - f"Loading options for profile '{profile_name}'...", func_name=func_name - ) - for key, default_value in keys_with_defaults.items(): - # Passa il default corretto - profile_data[key] = self.config_manager.get_profile_option( - profile_name, key, fallback=default_value - ) - - # --- Conversione Tipi (Opzionale qui, forse meglio nel chiamante) --- - # Se si decide di fare conversioni qui, assicurarsi che i valori di default - # in _get_expected_keys_with_defaults siano stringhe come in .ini - try: - profile_data["autocommit"] = ( - str(profile_data.get("autocommit", "False")).lower() == "true" - ) - profile_data["autobackup"] = ( - str(profile_data.get("autobackup", "False")).lower() == "true" - ) - # Aggiungere altre conversioni se necessario (es. per interi o float) - except Exception as e_conv: - log_handler.log_warning( - f"Error converting loaded profile data types for '{profile_name}': {e_conv}", - func_name=func_name, - ) - # Procedi con i dati stringa originali in caso di errore di conversione - - log_handler.log_debug( - f"Loaded data for '{profile_name}': {profile_data}", func_name=func_name - ) - return profile_data - - def save_profile_data(self, profile_name, profile_data): - """ - Saves the provided data dictionary to the specified profile name. - - Args: - profile_name (str): The name of the profile to save. - profile_data (dict): Dictionary containing the settings to save. - - Returns: - bool: True on success, False on failure. - """ - func_name = "save_profile_data" - # Validazione input - if not profile_name: - log_handler.log_warning( - "Cannot save: No profile name provided.", func_name=func_name - ) - return False - if not isinstance(profile_data, dict): - log_handler.log_error( - "Cannot save: Invalid profile data (not a dict).", func_name=func_name - ) - return False - - log_handler.log_info( - f"Saving data for profile: '{profile_name}'", func_name=func_name - ) - try: - # Itera sui dati da salvare - for key, value in profile_data.items(): - # Converte tipi Python in stringhe per configparser - if isinstance(value, bool): - value_to_save = str(value) # "True" o "False" - # Aggiungere altre conversioni se necessario (es. lista in stringa separata da virgola) - # elif isinstance(value, list): - # value_to_save = ",".join(map(str, value)) - else: - # Converte in stringa, gestendo None - value_to_save = str(value) if value is not None else "" - - # Imposta l'opzione tramite ConfigManager - self.config_manager.set_profile_option(profile_name, key, value_to_save) - - # Salva le modifiche sul file .ini - self.config_manager.save_config() - log_handler.log_info( - f"Profile '{profile_name}' saved successfully.", func_name=func_name - ) - return True - except Exception as e: - # Logga errore durante il salvataggio - log_handler.log_exception( - f"Error saving profile '{profile_name}': {e}", func_name=func_name - ) - # L'errore verrà probabilmente mostrato dal chiamante (GitUtility) - return False - - def add_new_profile(self, profile_name): - """ - Adds a new profile with default settings. - - Args: - profile_name (str): The name for the new profile. - - Returns: - bool: True if added successfully, False otherwise. - """ - func_name = "add_new_profile" - log_handler.log_info( - f"Attempting to add new profile: '{profile_name}'", func_name=func_name - ) - # Validazione nome - if not profile_name or profile_name.isspace(): - log_handler.log_warning( - "Add profile failed: Name cannot be empty.", func_name=func_name - ) - return False - if profile_name in self.config_manager.get_profile_sections(): - log_handler.log_warning( - f"Add profile failed: '{profile_name}' already exists.", - func_name=func_name, - ) - return False - - try: - # Ottieni i default da ConfigManager - defaults = self.config_manager._get_expected_keys_with_defaults() - # Personalizza alcuni default per il nuovo profilo - defaults["bundle_name"] = f"{profile_name}_repo.bundle" - defaults["bundle_name_updated"] = f"{profile_name}_update.bundle" - defaults["svn_working_copy_path"] = "" # Inizia vuoto - defaults["usb_drive_path"] = "" # Inizia vuoto - defaults["commit_message"] = ( - f"Initial commit for profile {profile_name}" # Esempio messaggio - ) - - # Aggiungi la sezione (se non esiste già - per sicurezza) - self.config_manager.add_section(profile_name) - # Imposta tutte le opzioni per la nuova sezione - for key, value in defaults.items(): - self.config_manager.set_profile_option( - profile_name, key, value - ) # set_profile_option gestisce la conversione a stringa - - # Salva il file di configurazione - self.config_manager.save_config() - log_handler.log_info( - f"Profile '{profile_name}' added successfully with defaults.", - func_name=func_name, - ) - return True - except Exception as e: - # Logga errori imprevisti durante l'aggiunta - log_handler.log_exception( - f"Error adding profile '{profile_name}': {e}", func_name=func_name - ) - return False - - def remove_existing_profile(self, profile_name): - """ - Removes an existing profile (cannot remove the default profile). - - Args: - profile_name (str): The name of the profile to remove. - - Returns: - bool: True if removed successfully, False otherwise. - """ - func_name = "remove_existing_profile" - log_handler.log_info( - f"Attempting to remove profile: '{profile_name}'", func_name=func_name - ) - # Validazioni - if not profile_name: - log_handler.log_warning( - "Remove profile failed: No name provided.", func_name=func_name - ) - return False - if profile_name == DEFAULT_PROFILE: - log_handler.log_warning( - "Remove profile failed: Cannot remove default profile.", - func_name=func_name, - ) - return False - if profile_name not in self.config_manager.get_profile_sections(): - log_handler.log_warning( - f"Remove profile failed: '{profile_name}' not found.", - func_name=func_name, - ) - return False - - try: - # Delega la rimozione a ConfigManager - success = self.config_manager.remove_profile_section(profile_name) - if success: - # Salva la configurazione solo se la rimozione è andata a buon fine - self.config_manager.save_config() - log_handler.log_info( - f"Profile '{profile_name}' removed successfully.", - func_name=func_name, - ) - return True - else: - # ConfigManager dovrebbe aver loggato il motivo del fallimento - log_handler.log_error( - f"ConfigManager reported failure removing '{profile_name}'.", - func_name=func_name, - ) - return False - except Exception as e: - # Logga errori imprevisti durante la rimozione - log_handler.log_exception( - f"Error removing profile '{profile_name}': {e}", func_name=func_name - ) - return False - - -# --- END OF FILE profile_handler.py ---