From ce36adddcc0513d61df765508b813b9f881ed7fd Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Mon, 28 Apr 2025 08:22:56 +0200 Subject: [PATCH] fix problem with compare remote/locale branch --- GitUtility.py | 228 ++++++++++++++++++++++++- async_result_handler.py | 56 ++++++ async_workers.py | 196 ++++++++++++++++++++- commit_detail_window.py | 365 ++++++++++++++++++++++++++++++++++++++++ gui.py | 33 +++- 5 files changed, 875 insertions(+), 3 deletions(-) create mode 100644 commit_detail_window.py diff --git a/GitUtility.py b/GitUtility.py index 5dc3995..4a6d92b 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -47,6 +47,7 @@ try: from gui import CloneFromRemoteDialog from diff_viewer import DiffViewerWindow from diff_summary_viewer import DiffSummaryWindow + from commit_detail_window import CommitDetailWindow # Asynchronous operations import async_workers @@ -171,9 +172,9 @@ class GitSvnSyncApp: delete_local_branch_cb=self.delete_local_branch, merge_local_branch_cb=self.merge_local_branch, compare_branch_with_current_cb=self.compare_branch_with_current, - # Instance and initial data for the GUI config_manager_instance=self.config_manager, profile_sections_list=self.config_manager.get_profile_sections(), + view_commit_details_cb=self.view_commit_details, ) print("MainFrame GUI created.") log_handler.log_debug("MainFrame GUI created successfully.", func_name="__init__") @@ -1940,6 +1941,165 @@ class GitSvnSyncApp: "repo_path": svn_path_str, # Pass path for potential conflict message } ) + + def show_commit_details(self, commit_details: Dict[str, Any]): + """ + Opens the CommitDetailWindow to display details of a specific commit. + Called by the AsyncResultHandler after fetching commit data. + + Args: + commit_details (Dict[str, Any]): A dictionary containing commit metadata + (hash, author, date, subject, body) + and a list of changed files. + """ + func_name: str = "show_commit_details" + log_handler.log_debug( + f"Attempting to show commit details for hash: {commit_details.get('hash_full', 'N/A')}", + func_name=func_name + ) + + # Validazione base dei dati ricevuti + if not isinstance(commit_details, dict) or not commit_details.get('hash_full'): + log_handler.log_error( + "Invalid or incomplete commit details received.", func_name=func_name + ) + if hasattr(self.main_frame, "show_error"): + self.main_frame.show_error( + "Display Error", "Internal error: Received invalid commit data." + ) + # Riabilita widget se i dati non sono validi + self._reenable_widgets_after_modal() + return + + try: + # Crea e mostra la finestra modale CommitDetailWindow + log_handler.log_debug( + f"Opening CommitDetailWindow for commit {commit_details.get('hash_full')[:7]}...", + func_name=func_name + ) + CommitDetailWindow( + master=self.master, # Parent è la finestra root + commit_data=commit_details, # Passa il dizionario dei dati + # Passa il callback per aprire il diff di un file specifico + open_diff_callback=self._open_commit_file_diff + ) + # Il codice attende qui finché la finestra CommitDetailWindow non viene chiusa + log_handler.log_info("Commit Detail window closed by user.", func_name=func_name) + # Ripristina la status bar dopo la chiusura della finestra + if hasattr(self.main_frame, "update_status_bar"): + self.main_frame.update_status_bar("Ready.") + + except Exception as e_detail: + # Gestisci errori durante la creazione/visualizzazione della finestra + log_handler.log_exception( + f"Error opening commit detail window: {e_detail}", func_name=func_name + ) + if hasattr(self.main_frame, "show_error") and hasattr(self.main_frame, "update_status_bar"): + self.main_frame.show_error( + "Display Error", f"Could not display commit details:\n{e_detail}" + ) + self.main_frame.update_status_bar("Error displaying commit details.") + finally: + # Assicurati che i widget vengano riabilitati dopo che la finestra + # (o il messaggio di errore) è stata chiusa. + self._reenable_widgets_after_modal() + + def _open_commit_file_diff( + self, + commit_hash: str, + file_status: str, + file_path: str, + old_file_path: Optional[str] = None + ): + """ + Opens the DiffViewerWindow to show the changes for a specific file + within a given commit compared to its parent. + + Args: + commit_hash (str): The full hash of the commit being viewed. + file_status (str): The status character (A, M, D, R, T...). + file_path (str): The relative path of the file in the commit. + old_file_path (Optional[str]): The old path (for Renamed files). + """ + func_name: str = "_open_commit_file_diff" + log_handler.log_info( + f"Requesting diff for file '{file_path}' in commit '{commit_hash[:7]}'", + func_name=func_name + ) + + # Validazione repo path corrente + svn_path: Optional[str] = self._get_and_validate_svn_path("Open Commit File Diff") + if not svn_path or not self._is_repo_ready(svn_path): + log_handler.log_error( + "Cannot open diff: Repository path invalid or not ready.", func_name=func_name + ) + if hasattr(self.main_frame, "show_error"): + self.main_frame.show_error("Error", "Cannot open diff: Repository not available.") + return + + # Determina i riferimenti per il diff viewer + ref1: str = f"{commit_hash}^" # Genitore del commit + ref2: str = commit_hash # Il commit stesso + + # --- Gestione Casi Speciali --- + # 1. File Aggiunto (A) o Copiato (C): Confronta con "stato vuoto" (come fare?) + # Git diff -- mostra l'intero file come aggiunto. + # Possiamo passare i ref standard, DiffViewerWindow lo mostrerà correttamente. + # 2. File Cancellato (D): Confronta il file nel parent con "stato vuoto". + # DiffViewerWindow dovrebbe mostrare il file come cancellato. + # Per Deleted files, il path da usare è quello *prima* della cancellazione. + # 3. File Rinominato (R): Dobbiamo usare old_file_path per ref1 e file_path per ref2? + # No, `git diff commit^ commit -- file` gestisce la rinomina. Usiamo il *nuovo* path. + # 4. Commit Iniziale (Root Commit): Non ha genitore (`commit^` fallisce). + # Dobbiamo rilevarlo. Possiamo provare `git rev-parse commit^`. Se fallisce, + # è un root commit. In tal caso, mostriamo il file solo in ref2 (vs "empty"). + # Per semplicità iniziale, potremmo anche solo mostrare un errore se `git show` + # per `commit^:path` fallisce. + + path_to_diff: str = file_path + if file_status == 'D' and old_file_path: + # Per file cancellati, in realtà vogliamo vedere il contenuto *prima* + # ma il diff tra parent e commit con il path nuovo non funziona. + # È più corretto mostrare il file come esisteva nel parent. + # Potremmo aprire DiffViewer in modo speciale solo con ref1? + # O semplicemente non permettere il diff per file cancellati in questa vista? + # Per ora, mostriamo un messaggio e non apriamo il diff per 'D'. + log_handler.log_info( + f"Diff view skipped for deleted file '{file_path}' in commit '{commit_hash[:7]}'.", + func_name=func_name + ) + if hasattr(self.main_frame, "show_info"): + self.main_frame.show_info( + "Diff Not Applicable", + f"Cannot show diff for file deleted in this commit:\n{file_path}" + ) + return + + # --- Apri DiffViewerWindow --- + log_handler.log_debug( + f"Opening DiffViewerWindow: Ref1='{ref1}', Ref2='{ref2}', Path='{path_to_diff}'", + func_name=func_name + ) + try: + # Istanzia e mostra la finestra di diff modale + DiffViewerWindow( + master=self.master, # Usa la finestra root come parent + git_commands=self.git_commands, + repo_path=svn_path, + relative_file_path=path_to_diff, # Usa il path corretto + ref1=ref1, # Commit genitore + ref2=ref2 # Commit selezionato + ) + log_handler.log_debug("Commit file diff viewer closed.", func_name=func_name) + # Non serve aggiornare status bar qui, la finestra CommitDetail è ancora aperta + except Exception as e_diff: + log_handler.log_exception( + f"Error opening commit file diff viewer: {e_diff}", func_name=func_name + ) + if hasattr(self.main_frame, "show_error"): + self.main_frame.show_error( + "Diff Viewer Error", f"Could not display file changes:\n{e_diff}" + ) def manual_backup(self): """ Starts async operation for creating a manual backup ZIP. """ @@ -2722,6 +2882,72 @@ class GitSvnSyncApp: "repo_path": svn_path, # Pass repo path for summary window } ) + + def view_commit_details(self, history_line: str): + """ + Callback triggered by double-clicking a line in the history view. + Extracts the commit hash and starts an async worker to fetch details. + """ + func_name: str = "view_commit_details" + log_handler.log_info( + f"--- Action Triggered: View Commit Details for line: '{history_line}' ---", + func_name=func_name + ) + + # Ensure main frame exists + if not hasattr(self, "main_frame") or not self.main_frame.winfo_exists(): return + # Validate repo path and readiness + svn_path: Optional[str] = self._get_and_validate_svn_path("View Commit Details") + if not svn_path or not self._is_repo_ready(svn_path): + log_handler.log_warning( + "View Commit Details skipped: Repo not ready.", func_name=func_name + ) + self.main_frame.show_error( + "Action Failed", "Repository path is not valid or not prepared." + ) + 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 + + # --- Start Async Worker to Get Commit Details --- + log_handler.log_info( + f"Fetching details for commit '{commit_hash_short}'...", func_name=func_name + ) + # Prepare arguments for the worker + 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 + args_tuple=args, + context_dict={ + "context": "get_commit_details", + "status_msg": f"Loading details for commit {commit_hash_short}", + "commit_hash": commit_hash_short # Passa l'hash nel contesto + } + ) # --- Remote Action Launchers --- def apply_remote_config(self): diff --git a/async_result_handler.py b/async_result_handler.py index 630e29b..a24233b 100644 --- a/async_result_handler.py +++ b/async_result_handler.py @@ -122,6 +122,7 @@ class AsyncResultHandler: 'push_tags_remote': self._handle_push_tags_remote_result, 'clone_remote': self._handle_clone_remote_result, 'checkout_tracking_branch': self._handle_checkout_tracking_branch_result, + 'get_commit_details': self._handle_get_commit_details_result, } # Get the appropriate handler method from the map @@ -586,6 +587,61 @@ class AsyncResultHandler: self.main_frame.show_error("Untrack Error", f"Failed:\n{message}") return trigger_refreshes, sync_refresh + + def _handle_get_commit_details_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: + """ + Handles the result of the 'get_commit_details' async task. + If successful, calls the main app to display the commit detail window. + """ + func_name: str = "_handle_get_commit_details_result" + status: Optional[str] = result_data.get('status') + message: Optional[str] = result_data.get('message') + # result_value qui è il dizionario commit_details dal worker + commit_details: Optional[Dict[str, Any]] = result_data.get('result') + + if status == 'success': + log_handler.log_info( + "Commit details retrieved successfully. Requesting detail window display...", + func_name=func_name + ) + # Verifica che i dettagli siano un dizionario valido + if isinstance(commit_details, dict): + # Chiama un nuovo metodo sull'app principale per mostrare la finestra + if hasattr(self.app, "show_commit_details") and callable(self.app.show_commit_details): + # Passa il dizionario completo dei dettagli + self.app.show_commit_details(commit_details) + # Nota: show_commit_details in GitUtility si occuperà di + # riabilitare i widget dopo la chiusura della finestra modale. + else: + log_handler.log_error( + "Cannot display commit details: 'show_commit_details' method missing on app.", + func_name=func_name + ) + if hasattr(self.main_frame, "show_error"): + self.main_frame.show_error( + "Internal Error", "Cannot display commit details window." + ) + # Riabilita subito i widget in caso di errore interno + self._reenable_widgets_after_modal() + else: + # Errore: il risultato non è nel formato atteso + log_handler.log_error( + f"Received success status for commit details, but result is not a dict: {type(commit_details)}", + func_name=func_name + ) + if hasattr(self.main_frame, "show_error"): + self.main_frame.show_error("Data Error", "Failed to process commit details.") + self._reenable_widgets_after_modal() + + elif status == 'error': + # Gestisce l'errore (es. commit hash non valido) + log_handler.log_error(f"Failed to get commit details: {message}", func_name=func_name) + if hasattr(self.main_frame, "show_error"): + self.main_frame.show_error("Commit Details Error", f"Could not get details:\n{message}") + # Widgets sono già riabilitati dal chiamante per stato 'error' + + # Ottenere dettagli commit non triggera altri refresh standard + return False, False def _handle_add_file_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: """ Handles the result of the 'add_file' async task. """ diff --git a/async_workers.py b/async_workers.py index 91cc797..adf04a8 100644 --- a/async_workers.py +++ b/async_workers.py @@ -1758,4 +1758,198 @@ def run_compare_branches_async( log_handler.log_debug(f"[Worker] Finished: Compare Branches '{ref1}' vs '{ref2}'", func_name=func_name) -# --- END OF FILE async_workers.py --- +def run_get_commit_details_async( + git_commands: GitCommands, + repo_path: str, + commit_hash: str, # Hash del commit (può essere breve) + results_queue: queue.Queue +): + """ + Worker to fetch detailed information about a specific commit, including + metadata and the list of changed files. Executed in a separate thread. + """ + func_name: str = "run_get_commit_details_async" + log_handler.log_debug( + f"[Worker] Started: Get details for commit '{commit_hash}' in '{repo_path}'", + func_name=func_name + ) + + # Dizionario per contenere i risultati + commit_details: Dict[str, Any] = { + 'hash_full': None, + 'author_name': None, + 'author_email': None, + 'author_date': None, + 'subject': None, + 'body': "", # Messaggio completo (potrebbe essere multilinea) + 'files_changed': [] # Lista di tuple (status, path1, [path2 for R/C]) + } + status: str = "error" # Default a errore + message: str = f"Could not retrieve details for commit '{commit_hash}'." + exception_obj: Optional[Exception] = None + + try: + # --- 1. Ottieni Metadati e Lista File con 'git show --name-status' --- + # Usiamo un formato specifico per estrarre facilmente i metadati principali + # Usiamo %H per hash completo, %an/%ae per autore, %ad per data autore, %s per soggetto + # Separatori specifici (es. |||) per dividere i campi in modo affidabile + # --name-status alla fine per ottenere la lista dei file + # -z usa NUL come terminatore per i nomi file (più robusto) + separator: str = "|||---|||" # Separatore improbabile nel contenuto + pretty_format: str = f"%H{separator}%an{separator}%ae{separator}%ad{separator}%s" + show_cmd: List[str] = [ + "git", + "show", + f"--pretty=format:{pretty_format}", + "--name-status", + "-z", # Usa NUL terminators per i file + commit_hash # Usa l'hash fornito (breve o completo) + ] + log_handler.log_debug( + f"[Worker] Executing git show command: {' '.join(show_cmd)}", + func_name=func_name + ) + # Esegui catturando l'output, check=False per gestire commit non validi + show_result = git_commands.log_and_execute( + command=show_cmd, + working_directory=repo_path, + check=False, # Gestiamo noi se il commit non viene trovato + capture=True, + hide_console=True, + log_output_level=logging.DEBUG + ) + + # Controlla se il comando 'git show' ha avuto successo + 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) + metadata_line: str = output_parts[0] + files_part_raw: str = output_parts[1] if len(output_parts) > 1 else "" + + # Estrai metadati dalla prima parte + meta_parts: List[str] = metadata_line.split(separator) + if len(meta_parts) == 5: + commit_details['hash_full'] = meta_parts[0].strip() + commit_details['author_name'] = meta_parts[1].strip() + commit_details['author_email'] = meta_parts[2].strip() + commit_details['author_date'] = meta_parts[3].strip() + commit_details['subject'] = meta_parts[4].strip() + log_handler.log_debug( + f"[Worker] Parsed metadata: Hash={commit_details['hash_full']}, Author={commit_details['author_name']}", + func_name=func_name + ) + else: + log_handler.log_warning( + f"[Worker] Could not parse metadata line correctly: {metadata_line}", + func_name=func_name + ) + # Non considerarlo un errore fatale, potremmo avere solo i file + + # Estrai lista file dalla seconda parte (gestione NUL terminator) + if files_part_raw: + file_entries: List[str] = files_part_raw.strip('\x00').split('\x00') + 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 + 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] + 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 + 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 + 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 + else: + log_handler.log_warning(f"[Worker] Incomplete A/M/D/T entry: {file_entries[i:]}", func_name=func_name) + break + else: + # Ignora elementi vuoti (potrebbero esserci NUL consecutivi?) + i += 1 + commit_details['files_changed'] = parsed_files + log_handler.log_debug( + f"[Worker] Parsed {len(parsed_files)} changed files.", func_name=func_name + ) + + # --- 2. Ottieni Corpo Messaggio Commit con 'git show -s --format=%B' --- + # Questo comando estrae solo il messaggio completo (soggetto + corpo) + body_cmd: List[str] = ["git", "show", "-s", "--format=%B", commit_hash] + body_result = git_commands.log_and_execute( + command=body_cmd, + working_directory=repo_path, + check=False, # Dovrebbe funzionare se show precedente ha funzionato + capture=True, + hide_console=True + ) + if body_result.returncode == 0: + commit_details['body'] = body_result.stdout.strip() + # Subject è già nel body, ma lo teniamo separato per potenziali usi futuri + log_handler.log_debug("[Worker] Fetched full commit message body.", func_name=func_name) + else: + log_handler.log_warning( + f"[Worker] Could not fetch commit body (RC={body_result.returncode}).", + func_name=func_name + ) + # Non fatale, usa solo il subject se disponibile + + # Se siamo arrivati qui, l'operazione ha avuto successo + status = "success" + message = f"Details retrieved successfully for commit '{commit_hash}'." + log_handler.log_info(f"[Worker] {message}", func_name=func_name) + + elif "unknown revision or path not in the working tree" in (show_result.stderr or "").lower(): + # Caso specifico: hash commit non valido o non trovato + status = "error" + message = f"Commit hash '{commit_hash}' not found or invalid." + log_handler.log_error(f"[Worker] {message}", func_name=func_name) + exception_obj = ValueError(message) # Usa ValueError per input errato + else: + # Altro errore da 'git show' + status = "error" + stderr_msg = show_result.stderr.strip() if show_result.stderr else "Unknown git show error" + message = f"Failed to get commit details (RC={show_result.returncode}): {stderr_msg}" + log_handler.log_error(f"[Worker] {message}", func_name=func_name) + exception_obj = GitCommandError(message, stderr=show_result.stderr) + + except GitCommandError as git_err: + # Errore durante l'esecuzione di un comando git (es. permessi, repo corrotto) + log_handler.log_exception( + f"[Worker] GitCommandError getting commit details: {git_err}", + func_name=func_name + ) + status = "error" + message = f"Git error retrieving commit details: {git_err}" + exception_obj = git_err + except Exception as e: + # Errore imprevisto nel worker + log_handler.log_exception( + f"[Worker] UNEXPECTED EXCEPTION getting commit details: {e}", + func_name=func_name + ) + status = "error" + message = f"Unexpected error retrieving commit details: {type(e).__name__}" + exception_obj = e + + # --- Metti il risultato nella coda --- + results_queue.put({ + "status": status, + "message": message, + "result": commit_details, # Passa il dizionario con i dettagli + "exception": exception_obj + }) + + log_handler.log_debug( + f"[Worker] Finished: Get details for commit '{commit_hash}'", + func_name=func_name + ) diff --git a/commit_detail_window.py b/commit_detail_window.py new file mode 100644 index 0000000..5c10538 --- /dev/null +++ b/commit_detail_window.py @@ -0,0 +1,365 @@ +# --- FILE: commit_detail_window.py --- + +import tkinter as tk +from tkinter import ttk +from tkinter import scrolledtext # Per il corpo del messaggio +import os +from typing import Dict, Any, List, Tuple, Callable, Optional + +# Import moduli locali (se necessari, come Tooltip o log_handler) +import log_handler +# Importa la classe Tooltip (assumendo sia definita in gui.py o altrove) +try: + # Prova a importare da gui.py se non è in un modulo separato + from gui import Tooltip +except ImportError: + # Fallback se Tooltip non è disponibile (potrebbe causare errori se usato) + log_handler.log_warning("Tooltip class not found, tooltips may be disabled.", func_name="import") + # Definisci una classe fittizia per evitare errori immediati + class Tooltip: + def __init__(self, widget, text): pass + + +class CommitDetailWindow(tk.Toplevel): + """ + Toplevel window to display detailed information about a specific Git commit, + including metadata and a list of changed files. Allows opening a diff viewer + for selected files. + """ + # Mappa per tradurre gli stati dei file (simile a DiffSummaryWindow) + STATUS_MAP: Dict[str, str] = { + 'A': 'Added', + 'M': 'Modified', + 'D': 'Deleted', + 'R': 'Renamed', + 'C': 'Copied', + 'T': 'Type Change', + 'U': 'Unmerged', + 'X': 'Unknown', + 'B': 'Broken', + # Aggiungi altri se necessario + } + + def __init__( + self, + master: tk.Misc, # Parent window + commit_data: Dict[str, Any], # Dati ricevuti dal worker + open_diff_callback: Callable[[str, str, str, Optional[str]], None] # Callback per aprire il diff + ): + """ + Initializes the Commit Detail window. + + Args: + master: The parent widget. + commit_data (Dict[str, Any]): Dictionary containing commit details: + 'hash_full', 'author_name', 'author_email', 'author_date', + 'subject', 'body', 'files_changed' (List[Tuple[status, path1, path2]]) + open_diff_callback (Callable): Function to call when a file is selected + for diffing. Expects args: + (commit_hash, file_status, file_path, old_file_path) + """ + super().__init__(master) + func_name: str = "__init__ (CommitDetail)" # Nome per logging + + # --- Validazione Input --- + if not isinstance(commit_data, dict): + raise TypeError("commit_data must be a dictionary.") + if not callable(open_diff_callback): + raise TypeError("open_diff_callback must be callable.") + + # Memorizza dati e callback + self.commit_data: Dict[str, Any] = commit_data + self.open_diff_callback: Callable[[str, str, str, Optional[str]], None] = open_diff_callback + # Estrai hash per usarlo nel titolo e nel callback diff + self.commit_hash: Optional[str] = commit_data.get('hash_full') + self.short_hash: str = self.commit_hash[:7] if self.commit_hash else "N/A" + + log_handler.log_info(f"Opening Commit Detail window for {self.short_hash}", func_name=func_name) + + # --- Configurazione Finestra --- + self.title(f"Commit Details - {self.short_hash}") + self.geometry("800x600") # Dimensioni suggerite + self.minsize(550, 400) + # Rendi modale rispetto al parent + self.grab_set() + self.transient(master) + + # --- Creazione Widget --- + self._create_widgets() + # Popola i widget con i dati del commit + self._populate_details() + + # Centra la finestra + self._center_window(master) + + # Imposta focus iniziale sulla lista dei file + if hasattr(self, "files_tree"): + self.files_tree.focus_set() + + + def _create_widgets(self): + """ Creates the main widgets for the commit detail view. """ + # Frame principale con padding + main_frame = ttk.Frame(self, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + # Configura espansione righe/colonne + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(1, weight=0) # Riga metadati non si espande molto + main_frame.rowconfigure(3, weight=1) # Riga corpo messaggio si espande + main_frame.rowconfigure(5, weight=2) # Riga lista file si espande di più + + # --- Frame Metadati --- + meta_frame = ttk.LabelFrame(main_frame, text="Commit Info", padding=(10, 5)) + meta_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5)) + meta_frame.columnconfigure(1, weight=1) # Colonna valori si espande + + row_idx: int = 0 + # Hash Completo + ttk.Label(meta_frame, text="Commit Hash:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=2) + self.hash_label = ttk.Label(meta_frame, text="", anchor="w", font=("Consolas", 9)) + self.hash_label.grid(row=row_idx, column=1, sticky="ew", padx=5, pady=2) + Tooltip(self.hash_label, "Full commit SHA-1 hash.") + row_idx += 1 + # Autore + ttk.Label(meta_frame, text="Author:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=2) + self.author_label = ttk.Label(meta_frame, text="", anchor="w") + self.author_label.grid(row=row_idx, column=1, sticky="ew", padx=5, pady=2) + Tooltip(self.author_label, "Commit author name and email.") + row_idx += 1 + # Data + ttk.Label(meta_frame, text="Date:").grid(row=row_idx, column=0, sticky="nw", padx=5, pady=2) + self.date_label = ttk.Label(meta_frame, text="", anchor="w") + self.date_label.grid(row=row_idx, column=1, sticky="ew", padx=5, pady=2) + Tooltip(self.date_label, "Author date.") + row_idx += 1 + + # --- Subject (Titolo Commit) --- + ttk.Label(main_frame, text="Subject:").grid(row=1, column=0, sticky="w", padx=5, pady=(5, 0)) + self.subject_label = ttk.Label( + main_frame, text="", anchor="w", font=("Segoe UI", 10, "bold"), relief=tk.GROOVE, padding=5 + ) + self.subject_label.grid(row=2, column=0, sticky="ew", padx=5, pady=(0, 5)) + Tooltip(self.subject_label, "Commit subject line.") + + # --- Corpo Messaggio Commit --- + ttk.Label(main_frame, text="Message Body:").grid(row=3, column=0, sticky="w", padx=5, pady=(0, 0)) + self.body_text = scrolledtext.ScrolledText( + master=main_frame, + height=6, # Altezza iniziale + wrap=tk.WORD, + font=("Segoe UI", 9), + state=tk.DISABLED, # Read-only + padx=5, + pady=5, + borderwidth=1, + relief=tk.SUNKEN, + ) + self.body_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) + Tooltip(self.body_text, "Full commit message body.") + + # --- Lista File Modificati --- + files_frame = ttk.LabelFrame(main_frame, text="Changed Files", padding=(10, 5)) + files_frame.grid(row=5, column=0, sticky="nsew", pady=(5, 0)) + files_frame.rowconfigure(0, weight=1) + files_frame.columnconfigure(0, weight=1) + + # Treeview per i file (migliore per colonne) + columns: Tuple[str, str] = ('status', 'file') + self.files_tree = ttk.Treeview( + master=files_frame, + columns=columns, + show='headings', # Mostra solo headings, non colonna #0 + selectmode='browse', # Seleziona solo una riga + height=7 # Altezza iniziale + ) + # Definisci headings + self.files_tree.heading('status', text='Status', anchor='w') + self.files_tree.heading('file', text='File Path', anchor='w') + # Definisci colonne + self.files_tree.column('status', width=80, stretch=tk.NO, anchor='w') + self.files_tree.column('file', width=600, stretch=tk.YES, anchor='w') + + # Scrollbar verticale + tree_scrollbar = ttk.Scrollbar( + master=files_frame, + orient=tk.VERTICAL, + command=self.files_tree.yview + ) + self.files_tree.configure(yscrollcommand=tree_scrollbar.set) + + # Layout Treeview e Scrollbar + self.files_tree.grid(row=0, column=0, sticky="nsew") + tree_scrollbar.grid(row=0, column=1, sticky="ns") + + # Binding per doppio click sulla lista file -> apre diff + self.files_tree.bind("", self._on_file_double_click) + Tooltip(self.files_tree, "Double-click a file to view changes in this commit.") + + + def _populate_details(self): + """ Populates the widgets with data from self.commit_data. """ + func_name: str = "_populate_details" + # --- Popola Metadati --- + if hasattr(self, "hash_label"): + self.hash_label.config(text=self.commit_data.get('hash_full', 'N/A')) + if hasattr(self, "author_label"): + name: str = self.commit_data.get('author_name', 'N/A') + email: str = self.commit_data.get('author_email', '') + author_text: str = f"{name} <{email}>" if email else name + self.author_label.config(text=author_text) + if hasattr(self, "date_label"): + self.date_label.config(text=self.commit_data.get('author_date', 'N/A')) + if hasattr(self, "subject_label"): + self.subject_label.config(text=self.commit_data.get('subject', '(No Subject)')) + + # --- Popola Corpo Messaggio --- + if hasattr(self, "body_text"): + body_content: str = self.commit_data.get('body', '(No Message Body)') + try: + self.body_text.config(state=tk.NORMAL) # Abilita per modifica + self.body_text.delete("1.0", tk.END) # Pulisci + self.body_text.insert(tk.END, body_content) # Inserisci testo + self.body_text.config(state=tk.DISABLED) # Disabilita di nuovo + except Exception as e: + log_handler.log_error(f"Error populating commit body text: {e}", func_name=func_name) + # Mostra errore nel widget stesso + try: + self.body_text.config(state=tk.NORMAL) + self.body_text.delete("1.0", tk.END) + self.body_text.insert(tk.END, f"(Error displaying body: {e})") + self.body_text.config(state=tk.DISABLED) + except Exception: pass # Ignora errori nel fallback + + # --- Popola Lista File Modificati --- + if hasattr(self, "files_tree"): + # Pulisci treeview precedente + for item in self.files_tree.get_children(): + self.files_tree.delete(item) + + # Ottieni lista file dal dizionario dati + files_changed: List[Tuple[str, str, Optional[str]]] = self.commit_data.get('files_changed', []) + + if files_changed: + # Inserisci ogni file nella treeview + for i, (status_char, path1, path2) in enumerate(files_changed): + display_status: str = self.STATUS_MAP.get(status_char, status_char) # Traduci stato + display_path: str = "" + # Gestisci visualizzazione per rinominati/copiati + if status_char in ('R', 'C') and path2: + display_path = f"{path1} -> {path2}" + else: + display_path = path1 # Usa path1 per A, M, D, T + + # Memorizza dati completi per il callback nel tag dell'item + # (Alternativa: usare un dizionario separato mappato per iid) + item_data_tag = f"file_{i}" + self.files_tree.insert( + parent='', + index=tk.END, + iid=i, # Usa indice come ID interno + values=(display_status, display_path), + tags=(item_data_tag,) # Associa un tag univoco + ) + # Salva i dati necessari per il diff nel tag config (un po' hacky ma funziona) + self.files_tree.tag_configure( + item_data_tag, + foreground='black' # Dummy config, usiamo solo per storage + ) + # Allega i dati veri al tag + self.files_tree.tag_bind( + item_data_tag, "", + lambda e, sc=status_char, p1=path1, p2=path2: + setattr(e.widget, f"_data_{e.widget.focus()}", (sc, p1, p2)) + ) + + + else: + # Mostra messaggio se non ci sono file cambiati (raro per un commit!) + self.files_tree.insert(parent='', index=tk.END, values=("", "(No files changed in commit data)")) + + + def _on_file_double_click(self, event: tk.Event): + """ Handles double-click on the files Treeview. """ + func_name: str = "_on_file_double_click" + if not hasattr(self, "files_tree"): return + + # Ottieni l'ID dell'item selezionato + selected_iid = self.files_tree.focus() # focus() restituisce l'iid dell'elemento con focus + if not selected_iid: return # Nessun item selezionato + + try: + # Recupera i dati associati all'item (dalla tupla originale) + item_index = int(selected_iid) + files_list: list = self.commit_data.get('files_changed', []) + + if 0 <= item_index < len(files_list): + status_char, path1, path2 = files_list[item_index] + + # Determina quale path passare e se c'era un path vecchio + file_path_to_diff: str = path2 if status_char in ('R', 'C') and path2 else path1 + old_file_path_for_diff: Optional[str] = path1 if status_char in ('R', 'C') else None + + # ---<<< MODIFICA: Non passare None come hash commit >>>--- + commit_hash_full: Optional[str] = self.commit_data.get('hash_full') + if commit_hash_full: + log_handler.log_debug( + f"Calling open_diff_callback for file: {file_path_to_diff}, commit: {commit_hash_full[:7]}", + func_name=func_name + ) + # Chiama il callback passato da GitUtility + self.open_diff_callback( + commit_hash_full, # Hash completo del commit + status_char, # Stato del file (A, M, D...) + file_path_to_diff, # Path del file nel commit + old_file_path_for_diff # Path vecchio (solo per R/C) + ) + else: + log_handler.log_error("Cannot open diff: Commit hash is missing.", func_name=func_name) + messagebox.showerror("Error", "Internal error: Commit hash not available.", parent=self) + # ---<<< FINE MODIFICA >>>--- + + else: + log_handler.log_error( + f"Selected Treeview item IID '{selected_iid}' out of range for data.", + func_name=func_name + ) + messagebox.showerror("Error", "Internal error: Selected item data not found.", parent=self) + + except ValueError: + log_handler.log_error(f"Invalid Treeview item IID: '{selected_iid}'", func_name=func_name) + messagebox.showerror("Error", "Internal error: Invalid item selected.", parent=self) + except Exception as e: + log_handler.log_exception(f"Error handling file double-click: {e}", func_name=func_name) + messagebox.showerror("Error", f"Could not process file selection:\n{e}", parent=self) + + + def _center_window(self, parent: tk.Misc): + """ Centers the Toplevel window relative to its parent. """ + func_name: str = "_center_window (CommitDetail)" + try: + self.update_idletasks() # Ensure window dimensions are calculated + # Get parent geometry + parent_x: int = parent.winfo_rootx() + parent_y: int = parent.winfo_rooty() + parent_w: int = parent.winfo_width() + parent_h: int = parent.winfo_height() + # Get self geometry + win_w: int = self.winfo_width() + win_h: int = self.winfo_height() + # Calculate position + pos_x: int = parent_x + (parent_w // 2) - (win_w // 2) + pos_y: int = parent_y + (parent_h // 2) - (win_h // 2) + # Keep window on screen + screen_w: int = self.winfo_screenwidth() + screen_h: int = self.winfo_screenheight() + pos_x = max(0, min(pos_x, screen_w - win_w)) + pos_y = max(0, min(pos_y, screen_h - win_h)) + # Set geometry + self.geometry(f"+{pos_x}+{pos_y}") + except Exception as e: + # Log error if centering fails + log_handler.log_error( + f"Could not center CommitDetailWindow: {e}", func_name=func_name + ) + +# --- FINE FILE commit_detail_window.py --- \ No newline at end of file diff --git a/gui.py b/gui.py index 95ef82e..4e7a68f 100644 --- a/gui.py +++ b/gui.py @@ -458,7 +458,7 @@ class MainFrame(ttk.Frame): delete_local_branch_cb: Callable[[str, bool], None], # Azione da menu contestuale locale merge_local_branch_cb: Callable[[str], None], # Azione da menu contestuale locale compare_branch_with_current_cb: Callable[[str], None], - # Aggiungere qui futuri callback (es. delete remote branch, compare branches, etc.) + view_commit_details_cb: Callable[[str], None] ): """Initializes the MainFrame.""" super().__init__(master) @@ -1486,6 +1486,10 @@ class MainFrame(ttk.Frame): relief=tk.SUNKEN, ) 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.create_tooltip(self.history_text, "Double-click a commit to view details.") + history_xscroll = ttk.Scrollbar( frame, orient=tk.HORIZONTAL, command=self.history_text.xview ) @@ -1719,6 +1723,33 @@ class MainFrame(ttk.Frame): self.commit_message_text.edit_reset() except Exception: pass + + def _on_history_double_click(self, event: tk.Event) -> None: + """ + Handles the double-click event on the history text area. + Extracts the selected line and calls the view_commit_details callback. + """ + # Ensure the callback exists and is callable + if not hasattr(self, "view_commit_details_callback") or \ + not callable(self.view_commit_details_callback): + return + + try: + # 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 + + # Pass the extracted line content to the callback function + if line_content and not line_content.startswith("("): # Avoid acting on "(Error..)" lines + self.view_commit_details_callback(line_content) + except tk.TclError: + # Ignore errors if click is outside text content + pass + except Exception as e: + # Log unexpected errors (consider using log_handler if available) + print(f"Error handling history double-click: {e}", file=sys.stderr) def update_history_display(self, log_lines): """Clears and populates the history ScrolledText widget."""