# --- FILE: diff_summary_viewer.py --- import tkinter as tk from tkinter import ttk, messagebox import os import log_handler # Per logging interno from typing import List, Tuple # Per type hints # Importa DiffViewerWindow per poterla aprire da qui try: from diff_viewer import DiffViewerWindow except ImportError: # Fallback o errore se DiffViewer non è trovabile (improbabile se nella stessa dir) log_handler.log_critical("DiffSummaryViewer: Cannot import DiffViewerWindow!", func_name="import") # Potremmo definire una classe fittizia per evitare errori, ma l'app non funzionerebbe class DiffViewerWindow: # Dummy class def __init__(self, *args, **kwargs): log_handler.log_error("Dummy DiffViewerWindow called!") messagebox.showerror("Import Error", "Cannot open detailed diff view.") # Importa GitCommands se serve per ottenere più info sui file? Per ora no. # from git_commands import GitCommands # --- Tooltip Class (Copiata o Importata) --- class Tooltip: """Simple tooltip implementation for Tkinter widgets.""" def __init__(self, widget, text): self.widget = widget self.text = text self.tooltip_window = None self.id = None if self.widget and self.widget.winfo_exists(): # Usiamo add='+' per non sovrascrivere altri binding Enter/Leave self.widget.bind("", self.enter, add="+") self.widget.bind("", self.leave, add="+") self.widget.bind("", self.leave, add="+") # Nasconde su click def enter(self, event=None): self.unschedule() # Pianifica la visualizzazione dopo un ritardo (es. 500ms) self.id = self.widget.after(500, self.showtip) def leave(self, event=None): self.unschedule() self.hidetip() def unschedule(self): id_to_cancel = self.id self.id = None if id_to_cancel: try: # Necessario controllare che widget esista prima di chiamare after_cancel if self.widget and self.widget.winfo_exists(): self.widget.after_cancel(id_to_cancel) except Exception: # Ignora errori se il widget è già distrutto pass def showtip(self): # Non mostrare se il widget non esiste più if not self.widget or not self.widget.winfo_exists(): return # Nascondi tooltip precedente se esiste self.hidetip() # Calcola posizione tooltip vicino al cursore o al widget x_cursor, y_cursor = 0, 0 try: # Prova a ottenere posizione cursore x_cursor = self.widget.winfo_pointerx() + 15 y_cursor = self.widget.winfo_pointery() + 10 except Exception: # Fallback: posizione relativa al widget try: x_root = self.widget.winfo_rootx() y_root = self.widget.winfo_rooty() x_cursor = x_root + self.widget.winfo_width() // 2 y_cursor = y_root + self.widget.winfo_height() + 5 except Exception: # Impossibile determinare posizione return # Crea la finestra Toplevel per il tooltip self.tooltip_window = tw = tk.Toplevel(self.widget) # Rimuovi decorazioni finestra (titolo, bordi standard) tw.wm_overrideredirect(True) try: # Posiziona la finestra tw.wm_geometry(f"+{int(x_cursor)}+{int(y_cursor)}") except tk.TclError: # Può fallire se le coordinate sono fuori schermo tw.destroy() self.tooltip_window = None return # Crea etichetta dentro la finestra Toplevel label = tk.Label( tw, text=self.text, justify=tk.LEFT, background="#ffffe0", # Sfondo giallo chiaro relief=tk.SOLID, borderwidth=1, font=("tahoma", "8", "normal"), # Font piccolo standard wraplength=350 # Larghezza massima prima di andare a capo ) label.pack(ipadx=3, ipady=3) # Piccolo padding interno def hidetip(self): tw = self.tooltip_window self.tooltip_window = None if tw: try: # Controlla se la finestra esiste prima di distruggerla if tw.winfo_exists(): tw.destroy() except Exception: # Ignora errori se la finestra è già distrutta pass class DiffSummaryWindow(tk.Toplevel): """ Toplevel window to display a summary of changed files between two Git references. Allows opening the detailed DiffViewerWindow for a selected file. """ # Dizionario per mappare lo status di git diff-tree a un testo più leggibile STATUS_MAP = { 'A': 'Added', 'M': 'Modified', 'D': 'Deleted', 'R': 'Renamed', # R 'C': 'Copied', # C 'T': 'Type Change', # Es. da file a symlink 'U': 'Unmerged', # In caso di conflitti (non dovrebbe apparire con diff-tree tra commit) 'X': 'Unknown', 'B': 'Broken', } def __init__(self, master, git_commands: 'GitCommands', # Forward declaration se GitCommands non importato qui repo_path: str, ref1: str, ref2: str, changed_files_status: List[str]): # Lista ["Status\tFilePath", ...] """ Initializes the Diff Summary window. Args: master: Parent widget. git_commands (GitCommands): Instance for Git interaction (needed for DiffViewer). repo_path (str): Absolute path to the Git repository. ref1 (str): The first reference (e.g., 'master'). ref2 (str): The second reference (e.g., 'origin/master'). changed_files_status (List[str]): List of strings from 'git diff-tree --name-status'. Format: "Status\tFilePath" or "Status\tOldPath\tNewPath" for R/C. """ super().__init__(master) func_name = "__init__ (DiffSummary)" # Validazione input if not git_commands: raise ValueError("git_commands instance is required.") if not repo_path: raise ValueError("repo_path is required.") if not ref1 or not ref2: raise ValueError("Both ref1 and ref2 are required.") self.master = master self.git_commands = git_commands self.repo_path = repo_path self.ref1 = ref1 self.ref2 = ref2 self.changed_files_data = self._parse_diff_tree_output(changed_files_status) # Processa l'input log_handler.log_info(f"Opening Diff Summary: '{ref1}' vs '{ref2}' ({len(self.changed_files_data)} files)", func_name=func_name) # --- Configurazione Finestra --- self.title(f"Differences: {ref1} vs {ref2}") # Dimensioni adattive? O fisse? Iniziamo con una dimensione ragionevole. self.geometry("700x450") self.minsize(450, 300) self.grab_set() # Modale self.transient(master) # --- Creazione Widget --- self._create_widgets() # Popola la Treeview self._populate_treeview() # Centra la finestra self._center_window(master) # Metti il focus sulla treeview if hasattr(self, "tree"): self.tree.focus_set() def _parse_diff_tree_output(self, diff_tree_lines: List[str]) -> List[Tuple[str, str, str]]: """ Parses the raw output of 'git diff-tree --name-status -r' into structured data. Handles Added, Deleted, Modified, and Renamed/Copied files. Args: diff_tree_lines (List[str]): Raw lines from git diff-tree. Returns: List[Tuple[str, str, str]]: List of tuples: (status_char, display_status, file_path_display) For Renamed/Copied, file_path_display includes both paths. """ parsed_data = [] for line in diff_tree_lines: parts = line.split('\t') if not parts: continue # Ignora linee vuote status_char = parts[0].strip() # Rimuovi eventuali score per Rename/Copy if status_char.startswith(('R', 'C')) and len(status_char) > 1: status_char = status_char[0] # Prendi solo R o C display_status = self.STATUS_MAP.get(status_char, 'Unknown') file_path_display = "Error parsing path" if status_char in ('R', 'C'): # Renamed o Copied if len(parts) == 3: old_path = parts[1] new_path = parts[2] file_path_display = f"{old_path} -> {new_path}" # Per il diff viewer, useremo il *nuovo* path se modificato/copiato, # o il *vecchio* path se cancellato (anche se il diff di un file cancellato vs uno esistente è complesso) # Memorizziamo entrambi per ora, decidiamo quale passare al diff viewer al momento del doppio click parsed_data.append((status_char, display_status, file_path_display, old_path, new_path)) else: log_handler.log_warning(f"Could not parse Renamed/Copied line: {line}", func_name="_parse_diff_tree_output") parsed_data.append((status_char, display_status, file_path_display, "", "")) # Placeholder elif status_char in ('A', 'M', 'D', 'T'): # Added, Modified, Deleted, Type Change if len(parts) == 2: file_path = parts[1] file_path_display = file_path parsed_data.append((status_char, display_status, file_path_display, file_path, file_path)) # old/new path sono gli stessi qui else: log_handler.log_warning(f"Could not parse A/M/D/T line: {line}", func_name="_parse_diff_tree_output") parsed_data.append((status_char, display_status, file_path_display, "", "")) # Placeholder else: # Stato sconosciuto o errore parsing iniziale log_handler.log_warning(f"Unknown status or parse error for line: {line}", func_name="_parse_diff_tree_output") file_path_display = line # Mostra linea grezza parsed_data.append((status_char, display_status, file_path_display, "", "")) return parsed_data def _create_widgets(self): """Creates the main widgets for the summary view.""" main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) main_frame.rowconfigure(0, weight=1) # La riga della Treeview si espande main_frame.columnconfigure(0, weight=1) # La colonna della Treeview si espande # --- Treeview per mostrare i file --- # Usiamo Treeview per avere colonne e ordinamento facile columns = ('status', 'file') self.tree = ttk.Treeview(main_frame, columns=columns, show='headings', selectmode='browse') # Definisci le colonne self.tree.heading('status', text='Status', command=lambda: self._sort_column('status', False)) self.tree.heading('file', text='File Path', command=lambda: self._sort_column('file', False)) # Imposta larghezze colonne (aggiusta come necessario) self.tree.column('status', width=80, stretch=tk.NO, anchor='w') self.tree.column('file', width=550, stretch=tk.YES, anchor='w') # Scrollbar tree_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL, command=self.tree.yview) self.tree.configure(yscrollcommand=tree_scrollbar.set) # Layout con Grid self.tree.grid(row=0, column=0, sticky='nsew') tree_scrollbar.grid(row=0, column=1, sticky='ns') # Binding per doppio click -> apre DiffViewer self.tree.bind("", self._on_item_double_click) # Frame per pulsanti in basso button_frame = ttk.Frame(main_frame, padding=(0, 10, 0, 0)) button_frame.grid(row=1, column=0, columnspan=2, sticky="ew") button_frame.columnconfigure(0, weight=1) # Spinge bottone a destra # Pulsante per aprire Diff (alternativa al doppio click) self.view_diff_button = ttk.Button(button_frame, text="View File Diff", command=self._open_selected_diff, state=tk.DISABLED) self.view_diff_button.grid(row=0, column=1, padx=5) Tooltip(self.view_diff_button, "Show detailed differences for the selected file.") # Abilita/Disabilita bottone View Diff in base alla selezione self.tree.bind("<>", self._on_selection_change) def _populate_treeview(self): """Populates the Treeview with the changed files data.""" if not hasattr(self, "tree"): return # Pulisci eventuali dati precedenti for item in self.tree.get_children(): self.tree.delete(item) # Inserisci i dati parsati # self.changed_files_data è List[Tuple[status_char, display_status, file_path_display, path1, path2]] for idx, data_tuple in enumerate(self.changed_files_data): status_char, display_status, file_path_display, path1, path2 = data_tuple # Inserisci riga nella treeview, usa 'idx' come ID interno # I valori inseriti sono quelli da visualizzare nelle colonne 'status' e 'file' self.tree.insert('', tk.END, iid=idx, values=(display_status, file_path_display)) # Ordina inizialmente per percorso file (opzionale) self._sort_column('file', False) def _sort_column(self, col, reverse): """Sorts the Treeview columns when a heading is clicked.""" try: # Ottieni dati da tutte le righe per la colonna cliccata # La tupla è (valore_visualizzato, id_riga) data = [(self.tree.set(child, col), child) for child in self.tree.get_children('')] # Ordina i dati (ignora maiuscole/minuscole per stringhe) data.sort(key=lambda t: t[0].lower() if isinstance(t[0], str) else t[0], reverse=reverse) # Riorganizza le righe nella Treeview for index, (val, child) in enumerate(data): self.tree.move(child, '', index) # Sposta la riga 'child' alla nuova 'index' # Inverti direzione ordinamento per il prossimo click sulla stessa colonna self.tree.heading(col, command=lambda: self._sort_column(col, not reverse)) except Exception as e: log_handler.log_error(f"Error sorting Treeview column '{col}': {e}", func_name="_sort_column") def _on_selection_change(self, event=None): """Enables/disables the 'View File Diff' button based on selection.""" if hasattr(self, "view_diff_button"): selected_items = self.tree.selection() state = tk.NORMAL if selected_items else tk.DISABLED # Non permettere diff per file cancellati nel lato 'ref2'? # Potremmo aggiungere un controllo qui se necessario. # Per ora, abilita se qualcosa è selezionato. self.view_diff_button.config(state=state) def _on_item_double_click(self, event=None): """Handles double-click event on a Treeview item.""" self._open_selected_diff() def _open_selected_diff(self): """Opens the DiffViewerWindow for the selected file.""" if not hasattr(self, "tree"): return selected_items = self.tree.selection() if not selected_items: log_handler.log_warning("View Diff clicked but no item selected.", func_name="_open_selected_diff") return selected_iid = selected_items[0] # Prendi il primo selezionato try: # Recupera i dati associati a questa riga (dal parsing originale) # L'IID della riga corrisponde all'indice nella nostra lista self.changed_files_data item_index = int(selected_iid) if 0 <= item_index < len(self.changed_files_data): data_tuple = self.changed_files_data[item_index] status_char, _, _, path1, path2 = data_tuple # Determina quale path passare al DiffViewer # Per Aggiunto (A), Modificato (M), Tipo Cambiato (T): usa il path unico (path1==path2) # Per Cancellato (D): usa il path del file cancellato (path1) # Per Rinominato (R) o Copiato (C): usa il *nuovo* path (path2) per vedere il contenuto finale file_to_diff = "" if status_char == 'D': file_to_diff = path1 else: # A, M, T, R, C (usa il path "destinazione") file_to_diff = path2 if not file_to_diff: log_handler.log_error(f"Could not determine file path for diff for item {selected_iid}, data: {data_tuple}", func_name="_open_selected_diff") messagebox.showerror("Error", "Could not determine the file path for comparison.", parent=self) return log_handler.log_info(f"Opening detailed diff for: '{file_to_diff}' ({self.ref1} vs {self.ref2})", func_name="_open_selected_diff") # Apri la DiffViewerWindow modificata # Passa i riferimenti originali (ref1, ref2) e il path relativo corretto DiffViewerWindow( master=self, # Usa questa finestra come parent git_commands=self.git_commands, repo_path=self.repo_path, relative_file_path=file_to_diff, ref1=self.ref1, # Riferimento per il lato sinistro ref2=self.ref2 # Riferimento per il lato destro ) else: log_handler.log_error(f"Selected Treeview item IID '{selected_iid}' out of range for data.", func_name="_open_selected_diff") 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="_open_selected_diff") messagebox.showerror("Error", "Internal error: Invalid item selected.", parent=self) except Exception as e: log_handler.log_exception(f"Error opening detailed diff: {e}", func_name="_open_selected_diff") messagebox.showerror("Error", f"Could not open detailed diff viewer:\n{e}", parent=self) def _center_window(self, parent): """Centers the Toplevel window relative to its parent.""" # (Codice identico a quello in DiffViewerWindow) # ... (omesso per brevità) ... pass # --- END OF FILE diff_summary_viewer.py ---