412 lines
19 KiB
Python
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 --- |