# --- START OF FILE diff_viewer.py --- import tkinter as tk from tkinter import ttk, scrolledtext, Canvas, messagebox import difflib import logging import os import locale # Per fallback encoding class DiffViewerWindow(tk.Toplevel): """ Toplevel window to display a side-by-side diff of a file. Shows differences between the working directory version and the HEAD version. Includes synchronized scrolling via clicking on the overview minimap. Relies solely on minimap clicks for vertical navigation. """ def __init__(self, master, logger, git_commands, repo_path, file_status_line): """ Initializes the Diff Viewer window. Args: master: The parent widget (usually the main Tkinter root). logger: Logger instance for logging messages. git_commands: Instance of GitCommands for interacting with Git. repo_path (str): Absolute path to the Git repository. file_status_line (str): The full status line from 'git status --short' for the file to be diffed (e.g., " M path/to/file.py"). """ super().__init__(master) # Setup Logger (con fallback) if not isinstance(logger, (logging.Logger, logging.LoggerAdapter)): print("ERROR: DiffViewerWindow requires a valid logger. Using fallback.") self.logger = logging.getLogger("DiffViewer_Fallback") self.logger.setLevel(logging.WARNING) # Logga solo warning/error else: self.logger = logger # Setup GitCommands if git_commands is None: self.logger.critical("GitCommands instance is required.") raise ValueError("DiffViewerWindow requires a valid GitCommands instance.") self.git_commands = git_commands self.repo_path = repo_path # Pulisci e valida il percorso del file dalla riga di stato self.relative_file_path = self._clean_relative_path(file_status_line) if not self.relative_file_path: self.logger.error(f"Cannot show diff: Invalid path from '{file_status_line}'.") messagebox.showerror("Error", "Invalid file path for diff.", parent=master) self.after_idle(self.destroy) # Chiudi se percorso non valido return # Esce dall'init # Configurazione Finestra Toplevel self.title(f"Diff - {os.path.basename(self.relative_file_path)}") self.geometry("920x650") # Dimensioni finestra self.minsize(550, 400) # Dimensioni minime self.grab_set() # Rendi modale self.transient(master) # Appare sopra il parent # Stato interno self.head_content_lines = [] self.workdir_content_lines = [] self.diff_map = [] # Mappa per minimappa: 0=uguale, 1=rimosso/mod, 2=aggiunto/mod self._scrolling_active = False # Flag per prevenire eventi ricorsivi # Costruisci l'interfaccia grafica self._create_widgets() # Carica il contenuto dei file e calcola/mostra il diff load_ok = False try: load_ok = self._load_content() if load_ok: self._compute_and_display_diff() self._setup_scrolling() # Configura solo il click sulla minimappa else: # Se _load_content fallisce, mostra errore nei widget self.logger.warning("Content loading failed, populating text widgets with error messages.") self._populate_text(self.text_head, [""]) self._populate_text(self.text_workdir, [""]) # Disegna comunque la minimappa (sarà vuota) self.minimap_canvas.after(50, self._draw_minimap) except Exception as load_err: # Errore imprevisto durante il caricamento/diff self.logger.exception(f"Unexpected error during diff setup for '{self.relative_file_path}': {load_err}") messagebox.showerror("Fatal Error", f"Failed to display diff:\n{load_err}", parent=self) self.after_idle(self.destroy) # Chiudi in caso di errore grave return # Esce dall'init # Centra la finestra rispetto al parent self._center_window(master) def _clean_relative_path(self, file_status_line): """ Extracts a clean relative path from a git status line. Handles status codes, spaces, renames, and quotes. """ try: # Pulisci caratteri speciali e spazi esterni line = file_status_line.strip('\x00').strip() if not line: self.logger.warning("Received empty status line for cleaning.") return "" # Trova il primo spazio per separare lo stato dal percorso space_index = -1 for i, char in enumerate(line): if char.isspace(): space_index = i break # Se non c'è spazio o è alla fine, il formato è sospetto if space_index == -1 or space_index + 1 >= len(line): # Se la linea non contiene spazi, potremmo assumere che sia solo il nome file? # Questo potrebbe accadere se lo stato viene perso prima. Per sicurezza, # richiediamo uno spazio per separare stato e percorso. self.logger.warning(f"Could not find valid space separator in status line: '{line}'") return "" # Ritorna vuoto se non c'è separatore valido # Estrai la parte del percorso dopo il primo spazio relative_path_raw = line[space_index + 1:].strip() # Gestisci rinominati nel formato "XY orig -> new" if "->" in relative_path_raw: # Prendi solo il nome del file *nuovo* (dopo ->) relative_path = relative_path_raw.split("->")[-1].strip() else: relative_path = relative_path_raw # Gestisci le quote se presenti (tipico se non si usa -z) if len(relative_path) >= 2 and relative_path.startswith('"') and relative_path.endswith('"'): relative_path = relative_path[1:-1] # Controlla se il risultato è vuoto dopo la pulizia if not relative_path: self.logger.warning(f"Extracted path is empty from status line: '{line}'") return "" self.logger.debug(f"Cleaned path from '{file_status_line}' -> '{relative_path}'") return relative_path except Exception as e: self.logger.error(f"Error cleaning path from status line '{file_status_line}': {e}", exc_info=True) return "" # Ritorna vuoto in caso di errore def _center_window(self, parent): """Centers the Toplevel window relative to its parent.""" try: self.update_idletasks() # Assicura dimensioni aggiornate # Calcola le coordinate parent_x = parent.winfo_rootx() parent_y = parent.winfo_rooty() parent_width = parent.winfo_width() parent_height = parent.winfo_height() window_width = self.winfo_width() window_height = self.winfo_height() x = parent_x + (parent_width // 2) - (window_width // 2) y = parent_y + (parent_height // 2) - (window_height // 2) # Assicura visibilità sullo schermo screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() x = max(0, min(x, screen_width - window_width)) y = max(0, min(y, screen_height - window_height)) # Imposta geometria self.geometry(f"+{int(x)}+{int(y)}") except Exception as e: self.logger.error(f"Could not center DiffViewerWindow: {e}", exc_info=True) def _create_widgets(self): """Creates the main widgets for the diff view (NO main scrollbar).""" main_frame = ttk.Frame(self, padding=5) main_frame.pack(fill=tk.BOTH, expand=True) main_frame.rowconfigure(1, weight=1) # Riga dei text widget si espande # Colonne: Sinistra (HEAD), Destra (Workdir), Minimap main_frame.columnconfigure(0, weight=1) main_frame.columnconfigure(1, weight=1) main_frame.columnconfigure(2, weight=0, minsize=40) # Minimap larghezza fissa # Etichette Titoli ttk.Label(main_frame, text=f"HEAD (Repository Version)").grid( row=0, column=0, sticky='w', padx=(5,2), pady=(0, 2)) ttk.Label(main_frame, text=f"Working Directory Version").grid( row=0, column=1, sticky='w', padx=(2,5), pady=(0, 2)) ttk.Label(main_frame, text="Overview").grid( row=0, column=2, sticky='w', padx=(5,0), pady=(0, 2)) # Widget Testo (senza scrollbar individuali o yscrollcommand) text_font = ("Consolas", 10) # Font Monospace common_text_opts = { "wrap": tk.NONE, # No a capo "font": text_font, "padx": 5, "pady": 5, "undo": False, # Non serve undo in visualizzazione "state": tk.DISABLED, # Inizia non modificabile "borderwidth": 1, "relief": tk.SUNKEN } self.text_head = tk.Text(main_frame, **common_text_opts) self.text_workdir = tk.Text(main_frame, **common_text_opts) self.text_head.grid(row=1, column=0, sticky='nsew', padx=(5, 2)) self.text_workdir.grid(row=1, column=1, sticky='nsew', padx=(2, 5)) # Minimap Canvas self.minimap_canvas = Canvas( main_frame, width=40, bg='#F0F0F0', relief=tk.SUNKEN, borderwidth=1, highlightthickness=0 ) self.minimap_canvas.grid(row=1, column=2, sticky='ns', padx=(5, 0)) self.minimap_canvas.bind("", self._on_minimap_resize) # Configurazione Tag per Highlighting self.text_head.tag_config("removed", background="#FFE0E0") # Rosso chiaro self.text_head.tag_config("empty", background="#F5F5F5", foreground="#A0A0A0") # Grigio per filler self.text_workdir.tag_config("added", background="#E0FFE0") # Verde chiaro self.text_workdir.tag_config("empty", background="#F5F5F5", foreground="#A0A0A0") # Grigio per filler def _load_content(self): """Loads file content from HEAD and working directory.""" self.logger.info(f"Loading content for diff: {self.relative_file_path}") load_success = True # Carica HEAD try: head_content_raw = self.git_commands.get_file_content_from_ref( self.repo_path, self.relative_file_path, ref="HEAD" ) self.head_content_lines = head_content_raw.splitlines() if head_content_raw is not None else [] if head_content_raw is None: self.logger.warning(f"File '{self.relative_file_path}' not found in HEAD (likely new or deleted in HEAD).") except Exception as e_head: self.logger.error(f"Error loading HEAD content for '{self.relative_file_path}': {e_head}", exc_info=True) self.head_content_lines = [f""] load_success = False # Carica Working Directory try: workdir_full_path = os.path.join(self.repo_path, self.relative_file_path) if os.path.exists(workdir_full_path) and os.path.isfile(workdir_full_path): try: with open(workdir_full_path, 'r', encoding='utf-8') as f: self.workdir_content_lines = f.read().splitlines() except UnicodeDecodeError: self.logger.warning(f"UTF-8 decode failed for WD {workdir_full_path}, trying fallback.") try: fallback_encoding = locale.getpreferredencoding(False) or 'latin-1' with open(workdir_full_path, 'r', encoding=fallback_encoding, errors='replace') as f: self.workdir_content_lines = f.read().splitlines() except Exception as fb_e: self.logger.error(f"WD fallback read failed: {fb_e}") self.workdir_content_lines = [f""] load_success = False else: self.logger.info(f"Working directory file not found: {workdir_full_path}") self.workdir_content_lines = [] # File non esiste sul disco except Exception as e_wd: self.logger.error(f"Error loading Workdir content for '{self.relative_file_path}': {e_wd}", exc_info=True) self.workdir_content_lines = [f""] load_success = False return load_success def _compute_and_display_diff(self): """Calculates the diff and populates the text widgets with highlights, ensuring consistent line count for display using SequenceMatcher.""" self.logger.debug("Computing and displaying diff...") if not self.head_content_lines and not self.workdir_content_lines: self.logger.warning("Both HEAD and Workdir content are empty or failed.") self._populate_text(self.text_head, ["(No content in HEAD)"]) self._populate_text(self.text_workdir, ["(No content in Workdir)"]) self.diff_map = [] self.minimap_canvas.after(50, self._draw_minimap) return matcher = difflib.SequenceMatcher(None, self.head_content_lines, self.workdir_content_lines, autojunk=False) head_lines_display = [] workdir_lines_display = [] diff_map_for_minimap = [] self.logger.debug("Generating aligned display lines using SequenceMatcher opcodes...") for tag, i1, i2, j1, j2 in matcher.get_opcodes(): head_chunk = self.head_content_lines[i1:i2] workdir_chunk = self.workdir_content_lines[j1:j2] if tag == 'equal': for k in range(len(head_chunk)): head_lines_display.append((' ', head_chunk[k])) workdir_lines_display.append((' ', workdir_chunk[k])) diff_map_for_minimap.append(0) # 0 = equal elif tag == 'delete': for k in range(len(head_chunk)): head_lines_display.append(('-', head_chunk[k])) workdir_lines_display.append(('empty', '')) diff_map_for_minimap.append(1) # 1 = deleted/modified head elif tag == 'insert': for k in range(len(workdir_chunk)): head_lines_display.append(('empty', '')) workdir_lines_display.append(('+', workdir_chunk[k])) diff_map_for_minimap.append(2) # 2 = added/modified workdir elif tag == 'replace': len_head = len(head_chunk) len_workdir = len(workdir_chunk) max_len = max(len_head, len_workdir) for k in range(max_len): head_line = head_chunk[k] if k < len_head else '' workdir_line = workdir_chunk[k] if k < len_workdir else '' head_code = '-' if k < len_head else 'empty' workdir_code = '+' if k < len_workdir else 'empty' head_lines_display.append((head_code, head_line)) workdir_lines_display.append((workdir_code, workdir_line)) # Segna come modificato (1 per rosso, 2 per verde) diff_map_for_minimap.append(1 if head_code != 'empty' else 2) # Verifica coerenza (opzionale ma utile per debug) if len(head_lines_display) != len(workdir_lines_display) or \ len(head_lines_display) != len(diff_map_for_minimap): self.logger.error("Internal Diff Error: Mismatch in aligned line counts!") else: self.logger.debug(f"Aligned display generated with {len(head_lines_display)} lines.") self.diff_map = diff_map_for_minimap # Aggiorna mappa per minimappa # Popola widget testo self._populate_text(self.text_head, head_lines_display) self._populate_text(self.text_workdir, workdir_lines_display) # Disegna minimappa (con ritardo) self.minimap_canvas.after(100, self._draw_minimap) def _populate_text(self, text_widget, lines_data): """Populates a text widget with lines and applies tags.""" widget_name = text_widget.winfo_name() self.logger.debug(f"Populating widget {widget_name} with {len(lines_data)} lines.") text_widget.config(state=tk.NORMAL) # Abilita per modifica text_widget.delete('1.0', tk.END) # Pulisci contenuto precedente if not lines_data: self.logger.debug(f"No lines data for {widget_name}.") text_widget.insert('1.0', "(No content)\n", ("empty",)) # Mostra messaggio vuoto else: content_string = "" tags_to_apply = [] # Lista di (tag_name, start_index, end_index) current_line_num = 1 # Tkinter usa indice 1-based for data_tuple in lines_data: if not isinstance(data_tuple, tuple) or len(data_tuple) != 2: self.logger.warning(f"Skipping malformed data in {widget_name}: {data_tuple}") line_content = "\n" code = 'error' # Codice fittizio per evitare errori tag else: code, content = data_tuple line_content = content + '\n' # Aggiungi newline per Text widget # Aggiungi il contenuto della riga alla stringa completa content_string += line_content # Calcola indici per i tag start_index = f"{current_line_num}.0" end_index = f"{current_line_num}.end" # .end si riferisce alla fine della linea logica # Aggiungi tag alla lista se necessario if code == '-': tags_to_apply.append(("removed", start_index, end_index)) elif code == '+': tags_to_apply.append(("added", start_index, end_index)) elif code == 'empty': tags_to_apply.append(("empty", start_index, end_index)) current_line_num += 1 # Incrementa numero linea per la prossima iterazione # Inserisci tutto il testo in una volta sola text_widget.insert('1.0', content_string) # Applica tutti i tag raccolti for tag, start, end in tags_to_apply: try: text_widget.tag_add(tag, start, end) except tk.TclError as tag_err: self.logger.error(f"Error applying tag '{tag}' from {start} to {end}: {tag_err}") text_widget.config(state=tk.DISABLED) # Disabilita dopo aver popolato text_widget.yview_moveto(0.0) # Assicura che sia all'inizio def _draw_minimap(self): """Draws the minimap overview based on self.diff_map.""" canvas = self.minimap_canvas canvas.delete("diff_line") canvas.delete("viewport_indicator") num_lines = len(self.diff_map) if num_lines == 0: self.logger.debug("No diff map data for minimap.") return canvas.update_idletasks() canvas_width = canvas.winfo_width() canvas_height = canvas.winfo_height() if canvas_width <= 1 or canvas_height <= 1: self.logger.debug(f"Skipping minimap draw due to invalid dimensions: {canvas_width}x{canvas_height}") return self.logger.debug(f"Drawing minimap ({canvas_width}x{canvas_height}) for {num_lines} lines.") # Calcola l'altezza ESATTA per linea, senza max(1.0) inizialmente # per vedere se il problema è l'arrotondamento exact_line_height = float(canvas_height) / num_lines if num_lines > 0 else 1.0 # --- MODIFICA: Arrotondamento e gestione bordo inferiore --- accumulated_height = 0.0 for i, diff_type in enumerate(self.diff_map): y_start = round(accumulated_height) # Arrotonda inizio # Calcola l'altezza *teorica* per questa linea current_line_height = exact_line_height accumulated_height += current_line_height y_end = round(accumulated_height) # Arrotonda fine # Assicura che y_end sia almeno y_start + 1 per visibilità if y_end <= y_start: y_end = y_start + 1 # Gestione ultima linea per riempire esattamente il canvas if i == num_lines - 1: y_end = canvas_height # Forza l'ultima linea ad arrivare al bordo # Colore (come modificato prima per avere rosso per tutte le diff) color = '#F0F0F0' # Grigio if diff_type == 1 or diff_type == 2: color = '#F8D0D0' # Rosso canvas.create_rectangle(0, y_start, canvas_width, y_end, fill=color, outline="", tags="diff_line") # --- FINE MODIFICA --- self._update_minimap_viewport() # --- Scrolling Logic (Solo Minimap Click) --- def _setup_scrolling(self): """ Configure ONLY minimap click for navigation. """ self.minimap_canvas.bind("", self._on_minimap_click) # Nessun altro binding necessario (no scrollbar, no yscrollcommand) def _reset_scroll_flag(self): """Resets the scrolling flag.""" self._scrolling_active = False def _update_minimap_viewport(self): """ Updates the indicator rectangle on the minimap. """ canvas = self.minimap_canvas canvas.delete("viewport_indicator") # Rimuovi il vecchio try: # Leggi la posizione della vista da uno dei widget testo first, last = self.text_head.yview() except tk.TclError: self.logger.warning("TclError getting text view, cannot update minimap viewport.") return # Esce se il widget è distrutto # Assicurati che le dimensioni siano valide canvas.update_idletasks() canvas_height = canvas.winfo_height() canvas_width = canvas.winfo_width() if canvas_height <= 1 or canvas_width <=1 : self.logger.debug("Canvas not ready for viewport update.") return # Calcola coordinate y del rettangolo indicatore y_start = first * canvas_height y_end = last * canvas_height # Assicura altezza minima di 1 pixel if y_end <= y_start : y_end = y_start + 1 # Disegna il nuovo indicatore canvas.create_rectangle( 1, y_start, canvas_width - 1, y_end, # Margine laterale 1px outline='black', width=1, tags="viewport_indicator" ) # Assicurati che sia sopra le linee colorate canvas.tag_raise("viewport_indicator") def _on_minimap_click(self, event): """ Handles clicks on the minimap to scroll the text views. """ if self._scrolling_active: return # Previene chiamate multiple self._scrolling_active = True try: canvas = self.minimap_canvas canvas_height = canvas.winfo_height() if canvas_height <= 1: return # Canvas non pronto # Calcola la frazione verticale del click target_fraction = event.y / canvas_height # Limita la frazione tra 0.0 e poco meno di 1.0 per evitare problemi target_fraction = max(0.0, min(target_fraction, 1.0)) self.logger.debug(f"Minimap clicked at y={event.y}, fraction={target_fraction:.3f}") # Muovi entrambi i widget di testo a quella frazione # yview_moveto imposta la *prima* linea visibile a quella frazione self.text_head.yview_moveto(target_fraction) self.text_workdir.yview_moveto(target_fraction) # Aggiorna l'indicatore sulla minimappa per riflettere la nuova vista self._update_minimap_viewport() finally: # Resetta il flag dopo un breve ritardo per evitare loop self.after(50, self._reset_scroll_flag) # Ritardo leggermente aumentato # Usiamo un debounce semplice per evitare ridisegni troppo frequenti durante il drag _configure_timer_id = None def _on_minimap_resize(self, event): """Called when the minimap canvas is resized (e.g., window resize). Schedules a redraw after a short delay (debounce).""" # Cancella il timer precedente se esiste if self._configure_timer_id: self.after_cancel(self._configure_timer_id) # Pianifica il ridisegno dopo un breve ritardo (es. 100ms) # Questo evita di ridisegnare continuamente mentre l'utente trascina il bordo self._configure_timer_id = self.after(100, self._redraw_minimap_on_resize) def _redraw_minimap_on_resize(self): """Actually redraws the minimap. Called by the debounced timer.""" self.logger.debug("Redrawing minimap due to resize event.") self._configure_timer_id = None # Resetta ID timer # Chiama la funzione di disegno esistente self._draw_minimap() # --- END OF FILE diff_viewer.py ---