SXXXXXXX_GitUtility/diff_summary_viewer.py

412 lines
19 KiB
Python

# --- 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("<Enter>", self.enter, add="+")
self.widget.bind("<Leave>", self.leave, add="+")
self.widget.bind("<ButtonPress>", 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<numero> <vecchio_path> <nuovo_path>
'C': 'Copied', # C<numero> <vecchio_path> <nuovo_path>
'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 <numero> 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("<Double-1>", 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("<<TreeviewSelect>>", 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 ---