934 lines
39 KiB
Python
934 lines
39 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext, Canvas, messagebox
|
|
import difflib
|
|
import os
|
|
import locale
|
|
import logging # Solo per livelli
|
|
from typing import List, Optional, TYPE_CHECKING # Aggiunto TYPE_CHECKING
|
|
|
|
from gitutility.logging_setup import log_handler
|
|
from gitutility.gui.tooltip import Tooltip
|
|
|
|
# Importa GitCommands dal nuovo percorso (con forward declaration)
|
|
try:
|
|
from gitutility.commands.git_commands import GitCommands, GitCommandError
|
|
except ImportError:
|
|
# Fallback nel caso estremamente raro che l'import fallisca
|
|
# (dovrebbe indicare un problema più grave di struttura o PYTHONPATH)
|
|
print("FATAL ERROR: Could not import GitCommands from gitsync_tool.commands in diff_viewer.py", file=sys.stderr)
|
|
# Definisci placeholder per permettere al codice di caricare, ma fallirà dopo
|
|
GitCommands = object # Un tipo generico per evitare NameError immediato
|
|
GitCommandError = Exception
|
|
|
|
|
|
# --- Tooltip Class Definition (Copiata da gui.py per standalone, o importata) ---
|
|
# (Assumiamo sia definita qui o importata correttamente se messa in un file separato)
|
|
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 DiffViewerWindow(tk.Toplevel):
|
|
"""
|
|
Toplevel window to display a side-by-side diff of a file.
|
|
Can show differences between working dir vs HEAD/Index, or between two Git references.
|
|
Includes synchronized scrolling via clicking on the overview minimap.
|
|
Uses log_handler for logging.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
master,
|
|
git_commands: GitCommands,
|
|
repo_path: str,
|
|
relative_file_path: str, # Path relativo del file
|
|
ref1: str, # Riferimento Sinistra (es. 'WORKING_DIR', 'HEAD', 'master', 'origin/dev')
|
|
ref2: str, # Riferimento Destra (es. 'HEAD', 'origin/main', commit_hash)
|
|
):
|
|
"""
|
|
Initializes the Diff Viewer window.
|
|
|
|
Args:
|
|
master: The parent widget.
|
|
git_commands (GitCommands): Instance for Git interaction.
|
|
repo_path (str): Absolute path to the Git repository.
|
|
relative_file_path (str): Relative path of the file within the repo.
|
|
ref1 (str): Reference for the left pane (e.g., 'WORKING_DIR', branch, tag, commit).
|
|
ref2 (str): Reference for the right pane (e.g., 'HEAD', branch, tag, commit).
|
|
"""
|
|
super().__init__(master)
|
|
func_name = "__init__ (DiffViewer)" # Nome per logging
|
|
|
|
# --- Validazione Input Base ---
|
|
if not isinstance(git_commands, GitCommands):
|
|
# Logga errore critico e solleva eccezione se git_commands non è valido
|
|
msg = "DiffViewerWindow requires a valid GitCommands instance."
|
|
log_handler.log_critical(msg, func_name=func_name)
|
|
# Potremmo mostrare messagebox qui, ma ValueError è più standard per argomenti
|
|
raise ValueError(msg)
|
|
if not repo_path or not os.path.isdir(repo_path):
|
|
msg = f"Invalid repository path provided: '{repo_path}'"
|
|
log_handler.log_error(msg, func_name=func_name)
|
|
raise ValueError(msg)
|
|
if not relative_file_path:
|
|
msg = "Relative file path cannot be empty."
|
|
log_handler.log_error(msg, func_name=func_name)
|
|
raise ValueError(msg)
|
|
if not ref1 or not ref2:
|
|
msg = "Both ref1 and ref2 must be specified for comparison."
|
|
log_handler.log_error(msg, func_name=func_name)
|
|
raise ValueError(msg)
|
|
|
|
# Memorizza argomenti
|
|
self.git_commands = git_commands
|
|
self.repo_path = repo_path
|
|
self.relative_file_path = relative_file_path
|
|
self.ref1 = ref1
|
|
self.ref2 = ref2
|
|
|
|
# Nomi brevi per visualizzazione nelle etichette e messaggi
|
|
self.ref1_display_name = (
|
|
"Working Dir" if self.ref1 == "WORKING_DIR" else self.ref1
|
|
)
|
|
self.ref2_display_name = (
|
|
"Working Dir" if self.ref2 == "WORKING_DIR" else self.ref2
|
|
)
|
|
|
|
log_handler.log_info(
|
|
f"Opening diff for '{self.relative_file_path}': Comparing '{self.ref1_display_name}' (Left) vs '{self.ref2_display_name}' (Right)",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# --- Configurazione Finestra Toplevel ---
|
|
self.title(f"Diff - {os.path.basename(self.relative_file_path)}")
|
|
self.geometry("920x650") # Dimensioni iniziali suggerite
|
|
self.minsize(550, 400) # Dimensioni minime
|
|
self.grab_set() # Rende modale rispetto al parent
|
|
self.transient(master) # Appare sopra il parent
|
|
|
|
# --- Stato Interno ---
|
|
self.pane1_content_lines: List[str] = [] # Contenuto sinistra
|
|
self.pane2_content_lines: List[str] = [] # Contenuto destra
|
|
self.diff_map: List[int] = [] # Mappa per minimap
|
|
self._scrolling_active: bool = False # Flag per scroll sincronizzato
|
|
self._configure_timer_id = None # ID per debounce resize minimap
|
|
self.loading_label: Optional[ttk.Label] = None
|
|
|
|
# --- Costruzione Interfaccia Grafica ---
|
|
log_handler.log_debug("Creating diff viewer widgets...", func_name=func_name)
|
|
self._create_widgets(self.ref1_display_name, self.ref2_display_name)
|
|
|
|
# --- Caricamento Contenuti e Calcolo Diff ---
|
|
log_handler.log_debug(
|
|
"Loading content and computing diff...", func_name=func_name
|
|
)
|
|
load_ok = False
|
|
try:
|
|
self._show_loading_message()
|
|
# Forza l'aggiornamento della GUI per mostrare il messaggio prima del blocco
|
|
self.update_idletasks()
|
|
|
|
load_ok = self._load_content() # Usa self.ref1 e self.ref2 internamente
|
|
if load_ok:
|
|
# Se il caricamento (anche parziale, es. file non trovato) è ok, calcola diff
|
|
self._compute_and_display_diff()
|
|
# Imposta lo scrolling sincronizzato (solo minimap click)
|
|
self._setup_scrolling()
|
|
else:
|
|
# Se _load_content ritorna False (errore critico lettura/git), mostra errore
|
|
log_handler.log_warning(
|
|
"Content loading failed critically, populating text widgets with error.",
|
|
func_name=func_name,
|
|
)
|
|
self._populate_text(
|
|
self.text_pane1,
|
|
[
|
|
(
|
|
"error",
|
|
f"<Error loading content for '{self.ref1_display_name}'>",
|
|
)
|
|
],
|
|
)
|
|
self._populate_text(
|
|
self.text_pane2,
|
|
[
|
|
(
|
|
"error",
|
|
f"<Error loading content for '{self.ref2_display_name}'>",
|
|
)
|
|
],
|
|
)
|
|
# Disegna comunque minimappa vuota
|
|
self.minimap_canvas.after(50, self._draw_minimap)
|
|
|
|
self._hide_loading_message()
|
|
|
|
except Exception as load_err:
|
|
# Errore imprevisto durante caricamento o calcolo diff
|
|
self._hide_loading_message()
|
|
log_handler.log_exception(
|
|
f"Unexpected error during diff setup for '{self.relative_file_path}': {load_err}",
|
|
func_name=func_name,
|
|
)
|
|
messagebox.showerror(
|
|
"Fatal Error", f"Failed to display diff:\n{load_err}", parent=self
|
|
)
|
|
# Chiudi la finestra se l'inizializzazione fallisce gravemente
|
|
self.after_idle(self.destroy)
|
|
return # Esce da __init__
|
|
|
|
# Centra la finestra dopo che i widget sono stati creati
|
|
self._center_window(master)
|
|
log_handler.log_debug(
|
|
"DiffViewerWindow initialized successfully.", func_name=func_name
|
|
)
|
|
|
|
def _center_window(self, parent):
|
|
"""Centers the Toplevel window relative to its parent."""
|
|
func_name = "_center_window (DiffViewer)"
|
|
try:
|
|
self.update_idletasks() # Assicura dimensioni widget aggiornate
|
|
# Calcola coordinate (come prima)
|
|
parent_x = parent.winfo_rootx()
|
|
parent_y = parent.winfo_rooty()
|
|
parent_w = parent.winfo_width()
|
|
parent_h = parent.winfo_height()
|
|
win_w = self.winfo_width()
|
|
win_h = self.winfo_height()
|
|
pos_x = parent_x + (parent_w // 2) - (win_w // 2)
|
|
pos_y = parent_y + (parent_h // 2) - (win_h // 2)
|
|
# Assicura visibilità
|
|
screen_w = self.winfo_screenwidth()
|
|
screen_h = 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))
|
|
# Imposta geometria
|
|
self.geometry(f"+{int(pos_x)}+{int(pos_y)}")
|
|
except Exception as e:
|
|
log_handler.log_error(
|
|
f"Could not center DiffViewerWindow: {e}", func_name=func_name
|
|
)
|
|
|
|
def _create_widgets(self, label1: str, label2: str):
|
|
"""Creates the main widgets for the diff view, using provided labels."""
|
|
main_frame = ttk.Frame(self, padding=5)
|
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
main_frame.rowconfigure(1, weight=1) # Riga text widget si espande
|
|
main_frame.columnconfigure(0, weight=1) # Colonna Sinistra (Pane 1)
|
|
main_frame.columnconfigure(1, weight=1) # Colonna Destra (Pane 2)
|
|
main_frame.columnconfigure(2, weight=0, minsize=40) # Minimap
|
|
|
|
# Etichette Titoli (usano i parametri passati)
|
|
ttk.Label(main_frame, text=label1).grid(
|
|
row=0, column=0, sticky="w", padx=(5, 2), pady=(0, 2)
|
|
)
|
|
ttk.Label(main_frame, text=label2).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 (rinominati pane1/pane2)
|
|
text_font = ("Consolas", 10) # Font Monospace
|
|
common_text_opts = {
|
|
"wrap": tk.NONE,
|
|
"font": text_font,
|
|
"padx": 5,
|
|
"pady": 5,
|
|
"undo": False,
|
|
"state": tk.DISABLED,
|
|
"borderwidth": 1,
|
|
"relief": tk.SUNKEN,
|
|
}
|
|
# Pane Sinistra
|
|
self.text_pane1 = tk.Text(main_frame, name="text_pane1", **common_text_opts)
|
|
self.text_pane1.grid(row=1, column=0, sticky="nsew", padx=(5, 2))
|
|
# Pane Destra
|
|
self.text_pane2 = tk.Text(main_frame, name="text_pane2", **common_text_opts)
|
|
self.text_pane2.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))
|
|
# Ridisegna su resize
|
|
self.minimap_canvas.bind("<Configure>", self._on_minimap_resize)
|
|
|
|
self.loading_label = ttk.Label(
|
|
main_frame, # Appoggia al main_frame per essere sopra gli altri
|
|
text="Loading file versions, please wait...", # Testo in inglese
|
|
font=("Segoe UI", 12, "italic"),
|
|
background="#FFFACD", # Giallo chiaro
|
|
foreground="#555555", # Grigio scuro
|
|
relief=tk.SOLID,
|
|
borderwidth=1,
|
|
padding=(10, 5),
|
|
)
|
|
|
|
# Configurazione Tag Highlighting (aggiornato nomi widget)
|
|
self.text_pane1.tag_config(
|
|
"removed", background="#FFE0E0"
|
|
) # Righe solo a sinistra
|
|
self.text_pane1.tag_config(
|
|
"empty", background="#F5F5F5", foreground="#A0A0A0"
|
|
) # Filler
|
|
self.text_pane2.tag_config("added", background="#E0FFE0") # Righe solo a destra
|
|
self.text_pane2.tag_config(
|
|
"empty", background="#F5F5F5", foreground="#A0A0A0"
|
|
) # Filler
|
|
# Tag per errori (comune)
|
|
self.text_pane1.tag_config("error", background="yellow", foreground="red")
|
|
self.text_pane2.tag_config("error", background="yellow", foreground="red")
|
|
|
|
def _load_content(self) -> bool:
|
|
"""Loads file content based on self.ref1 and self.ref2."""
|
|
func_name = "_load_content (DiffViewer)"
|
|
log_handler.log_info(
|
|
f"Loading content for diff: '{self.relative_file_path}' ({self.ref1} vs {self.ref2})",
|
|
func_name=func_name,
|
|
)
|
|
load_success = True # Traccia successo globale
|
|
|
|
# --- Carica Contenuto PANE 1 (Sinistra) ---
|
|
ref1_source = self.ref1
|
|
log_handler.log_debug(
|
|
f"Loading content for Pane 1 from: {ref1_source}", func_name=func_name
|
|
)
|
|
content_pane1_raw = "" # Inizializza
|
|
|
|
if ref1_source == "WORKING_DIR":
|
|
# Leggi dal filesystem locale
|
|
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: # Prova UTF-8
|
|
with open(workdir_full_path, "r", encoding="utf-8") as f:
|
|
content_pane1_raw = f.read()
|
|
except UnicodeDecodeError: # Fallback
|
|
log_handler.log_warning(
|
|
f"UTF-8 decode failed for WD '{workdir_full_path}', trying fallback.",
|
|
func_name=func_name,
|
|
)
|
|
try:
|
|
fallback_encoding = (
|
|
locale.getpreferredencoding(False) or "latin-1"
|
|
)
|
|
log_handler.log_debug(
|
|
f"Using fallback encoding: {fallback_encoding}",
|
|
func_name=func_name,
|
|
)
|
|
with open(
|
|
workdir_full_path,
|
|
"r",
|
|
encoding=fallback_encoding,
|
|
errors="replace",
|
|
) as f:
|
|
content_pane1_raw = f.read()
|
|
except Exception as fb_e: # Errore anche nel fallback
|
|
log_handler.log_error(
|
|
f"WD fallback read failed for '{workdir_full_path}': {fb_e}",
|
|
func_name=func_name,
|
|
)
|
|
content_pane1_raw = (
|
|
f"<Error reading WD with fallback: {fb_e}>"
|
|
)
|
|
load_success = False # Errore grave lettura
|
|
else: # File non trovato sul disco
|
|
log_handler.log_info(
|
|
f"Working directory file not found for Pane 1: {workdir_full_path}",
|
|
func_name=func_name,
|
|
)
|
|
content_pane1_raw = "<File not found in Working Directory>"
|
|
# Consideriamo questo un successo parziale (l'altra parte potrebbe caricare)
|
|
except Exception as e_wd: # Errore generico lettura WD
|
|
log_handler.log_exception(
|
|
f"Error loading WD content for Pane 1 ('{self.relative_file_path}'): {e_wd}",
|
|
func_name=func_name,
|
|
)
|
|
content_pane1_raw = f"<Error loading Working Dir: {e_wd}>"
|
|
load_success = False # Errore grave
|
|
self.pane1_content_lines = content_pane1_raw.splitlines()
|
|
|
|
else:
|
|
# Leggi da riferimento Git
|
|
try:
|
|
# Chiama il metodo GitCommands che gestisce 'git show ref:path'
|
|
content_pane1_raw = self.git_commands.get_file_content_from_ref(
|
|
self.repo_path, self.relative_file_path, ref=ref1_source
|
|
)
|
|
if content_pane1_raw is None: # File non trovato nel ref Git
|
|
log_handler.log_info(
|
|
f"File '{self.relative_file_path}' not found in ref '{ref1_source}' for Pane 1.",
|
|
func_name=func_name,
|
|
)
|
|
content_pane1_raw = f"<File not found in '{ref1_source}'>"
|
|
# Successo parziale
|
|
self.pane1_content_lines = content_pane1_raw.splitlines()
|
|
except Exception as e_ref1: # Errore durante 'git show'
|
|
log_handler.log_exception(
|
|
f"Error loading Git ref '{ref1_source}' for Pane 1 ('{self.relative_file_path}'): {e_ref1}",
|
|
func_name=func_name,
|
|
)
|
|
self.pane1_content_lines = [
|
|
f"<Error loading '{ref1_source}': {e_ref1}>"
|
|
]
|
|
load_success = False # Errore grave
|
|
|
|
# --- Carica Contenuto PANE 2 (Destra) ---
|
|
# (Logica speculare a Pane 1, usando ref2_source)
|
|
ref2_source = self.ref2
|
|
log_handler.log_debug(
|
|
f"Loading content for Pane 2 from: {ref2_source}", func_name=func_name
|
|
)
|
|
content_pane2_raw = "" # Inizializza
|
|
|
|
if ref2_source == "WORKING_DIR":
|
|
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:
|
|
content_pane2_raw = f.read()
|
|
except UnicodeDecodeError:
|
|
log_handler.log_warning(
|
|
f"UTF-8 decode failed for WD '{workdir_full_path}' (Pane 2), trying fallback.",
|
|
func_name=func_name,
|
|
)
|
|
try:
|
|
fallback_encoding = (
|
|
locale.getpreferredencoding(False) or "latin-1"
|
|
)
|
|
with open(
|
|
workdir_full_path,
|
|
"r",
|
|
encoding=fallback_encoding,
|
|
errors="replace",
|
|
) as f:
|
|
content_pane2_raw = f.read()
|
|
except Exception as fb_e:
|
|
log_handler.log_error(
|
|
f"WD fallback read failed for '{workdir_full_path}' (Pane 2): {fb_e}",
|
|
func_name=func_name,
|
|
)
|
|
content_pane2_raw = (
|
|
f"<Error reading WD with fallback: {fb_e}>"
|
|
)
|
|
load_success = False
|
|
else:
|
|
log_handler.log_info(
|
|
f"Working directory file not found for Pane 2: {workdir_full_path}",
|
|
func_name=func_name,
|
|
)
|
|
content_pane2_raw = "<File not found in Working Directory>"
|
|
except Exception as e_wd2:
|
|
log_handler.log_exception(
|
|
f"Error loading WD content for Pane 2 ('{self.relative_file_path}'): {e_wd2}",
|
|
func_name=func_name,
|
|
)
|
|
content_pane2_raw = f"<Error loading Working Dir: {e_wd2}>"
|
|
load_success = False
|
|
self.pane2_content_lines = content_pane2_raw.splitlines()
|
|
else:
|
|
# Leggi da riferimento Git
|
|
try:
|
|
content_pane2_raw = self.git_commands.get_file_content_from_ref(
|
|
self.repo_path, self.relative_file_path, ref=ref2_source
|
|
)
|
|
if content_pane2_raw is None:
|
|
log_handler.log_info(
|
|
f"File '{self.relative_file_path}' not found in ref '{ref2_source}' for Pane 2.",
|
|
func_name=func_name,
|
|
)
|
|
content_pane2_raw = f"<File not found in '{ref2_source}'>"
|
|
self.pane2_content_lines = content_pane2_raw.splitlines()
|
|
except Exception as e_ref2:
|
|
log_handler.log_exception(
|
|
f"Error loading Git ref '{ref2_source}' for Pane 2 ('{self.relative_file_path}'): {e_ref2}",
|
|
func_name=func_name,
|
|
)
|
|
self.pane2_content_lines = [
|
|
f"<Error loading '{ref2_source}': {e_ref2}>"
|
|
]
|
|
load_success = False
|
|
|
|
# Ritorna True se non ci sono stati errori gravi di caricamento
|
|
return load_success
|
|
|
|
def _compute_and_display_diff(self):
|
|
"""Calculates diff using SequenceMatcher and populates text widgets."""
|
|
func_name = "_compute_and_display_diff (DiffViewer)"
|
|
log_handler.log_debug("Computing and displaying diff...", func_name=func_name)
|
|
|
|
# Usa le variabili membro aggiornate
|
|
lines1 = self.pane1_content_lines
|
|
lines2 = self.pane2_content_lines
|
|
|
|
# Caso base: nessun contenuto
|
|
if not lines1 and not lines2:
|
|
log_handler.log_warning(
|
|
"Both panes have empty content for diff.", func_name=func_name
|
|
)
|
|
self._populate_text(
|
|
self.text_pane1,
|
|
[("empty", f"(No content in '{self.ref1_display_name}')")],
|
|
)
|
|
self._populate_text(
|
|
self.text_pane2,
|
|
[("empty", f"(No content in '{self.ref2_display_name}')")],
|
|
)
|
|
self.diff_map = []
|
|
self.minimap_canvas.after(
|
|
50, self._draw_minimap
|
|
) # Aggiorna minimappa vuota
|
|
return
|
|
|
|
# Calcola differenze
|
|
matcher = difflib.SequenceMatcher(None, lines1, lines2, autojunk=False)
|
|
|
|
# Prepara liste per visualizzazione allineata
|
|
pane1_lines_display = [] # Righe per PANE 1 (Sinistra)
|
|
pane2_lines_display = [] # Righe per PANE 2 (Destra)
|
|
diff_map_for_minimap = [] # Mappa colori per minimappa
|
|
|
|
log_handler.log_debug(
|
|
"Generating aligned display lines using opcodes...", func_name=func_name
|
|
)
|
|
# Itera sugli opcodes del SequenceMatcher
|
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
chunk1 = lines1[i1:i2] # Chunk da lista sinistra
|
|
chunk2 = lines2[j1:j2] # Chunk da lista destra
|
|
|
|
if tag == "equal": # Righe uguali
|
|
for k in range(len(chunk1)):
|
|
pane1_lines_display.append(
|
|
("equal", chunk1[k])
|
|
) # Nessun tag speciale o 'equal'
|
|
pane2_lines_display.append(("equal", chunk2[k]))
|
|
diff_map_for_minimap.append(0) # 0 = uguale (grigio)
|
|
elif tag == "delete": # Presente solo in Pane 1 (sinistra) -> Rimosso
|
|
for k in range(len(chunk1)):
|
|
pane1_lines_display.append(
|
|
("removed", chunk1[k])
|
|
) # Tag 'removed' per sinistra
|
|
pane2_lines_display.append(("empty", "")) # Filler per destra
|
|
diff_map_for_minimap.append(1) # 1 = rimosso/modificato (rosso)
|
|
elif tag == "insert": # Presente solo in Pane 2 (destra) -> Aggiunto
|
|
for k in range(len(chunk2)):
|
|
pane1_lines_display.append(("empty", "")) # Filler per sinistra
|
|
pane2_lines_display.append(
|
|
("added", chunk2[k])
|
|
) # Tag 'added' per destra
|
|
diff_map_for_minimap.append(2) # 2 = aggiunto/modificato (verde)
|
|
elif tag == "replace": # Modificato (diverso tra sinistra e destra)
|
|
len1 = len(chunk1)
|
|
len2 = len(chunk2)
|
|
max_len = max(len1, len2)
|
|
for k in range(max_len):
|
|
line1 = chunk1[k] if k < len1 else ""
|
|
line2 = chunk2[k] if k < len2 else ""
|
|
tag1 = (
|
|
"removed" if k < len1 else "empty"
|
|
) # Sinistra usa 'removed' o filler
|
|
tag2 = (
|
|
"added" if k < len2 else "empty"
|
|
) # Destra usa 'added' o filler
|
|
pane1_lines_display.append((tag1, line1))
|
|
pane2_lines_display.append((tag2, line2))
|
|
# Minimap: rosso se c'è contenuto a sinistra, verde altrimenti
|
|
minimap_code = 1 if tag1 != "empty" else 2
|
|
diff_map_for_minimap.append(minimap_code)
|
|
|
|
# Controllo coerenza (debug)
|
|
if len(pane1_lines_display) != len(pane2_lines_display) or len(
|
|
pane1_lines_display
|
|
) != len(diff_map_for_minimap):
|
|
log_handler.log_error(
|
|
"Internal Diff Error: Mismatch in aligned line counts!",
|
|
func_name=func_name,
|
|
)
|
|
# Potremmo mostrare errore all'utente o popolare con messaggio di errore
|
|
|
|
# Aggiorna mappa per minimap
|
|
self.diff_map = diff_map_for_minimap
|
|
|
|
# Popola i widget di testo
|
|
log_handler.log_debug(
|
|
"Populating text widgets with aligned lines...", func_name=func_name
|
|
)
|
|
self._populate_text(self.text_pane1, pane1_lines_display) # Popola PANE 1
|
|
self._populate_text(self.text_pane2, pane2_lines_display) # Popola PANE 2
|
|
|
|
# Disegna/Aggiorna minimappa (con leggero ritardo)
|
|
log_handler.log_debug("Scheduling minimap draw.", func_name=func_name)
|
|
self.minimap_canvas.after(100, self._draw_minimap)
|
|
|
|
def _populate_text(self, text_widget: tk.Text, lines_data: List[tuple[str, str]]):
|
|
"""Populates a text widget with lines data, applying tags."""
|
|
func_name = "_populate_text"
|
|
widget_name = "unknown_widget"
|
|
try:
|
|
widget_name = text_widget.winfo_name()
|
|
except Exception:
|
|
pass
|
|
log_handler.log_debug(
|
|
f"Populating widget '{widget_name}' with {len(lines_data)} lines.",
|
|
func_name=func_name,
|
|
)
|
|
|
|
try:
|
|
# Abilita, pulisci, inserisci, applica tag, disabilita
|
|
original_state = text_widget.cget("state")
|
|
text_widget.config(state=tk.NORMAL)
|
|
text_widget.delete("1.0", tk.END)
|
|
|
|
if not lines_data:
|
|
text_widget.insert("1.0", "(No content)\n", ("empty",))
|
|
else:
|
|
content_string = ""
|
|
tags_to_apply = []
|
|
current_line_num = 1
|
|
for tag_code, content in lines_data:
|
|
line_content = content + "\n"
|
|
content_string += line_content
|
|
start_index = f"{current_line_num}.0"
|
|
end_index = f"{current_line_num}.end"
|
|
# Mappa codice tag a nome tag effettivo
|
|
if tag_code in ["removed", "added", "empty", "error"]:
|
|
tags_to_apply.append((tag_code, start_index, end_index))
|
|
current_line_num += 1
|
|
|
|
# Inserisci tutto il testo
|
|
text_widget.insert("1.0", content_string)
|
|
# Applica i tag
|
|
for tag_name, start, end in tags_to_apply:
|
|
try:
|
|
text_widget.tag_add(tag_name, start, end)
|
|
except tk.TclError as tag_err:
|
|
log_handler.log_error(
|
|
f"Error applying tag '{tag_name}' from {start} to {end}: {tag_err}",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Ripristina stato e posizione vista
|
|
text_widget.config(state=tk.DISABLED)
|
|
text_widget.yview_moveto(0.0)
|
|
log_handler.log_debug(
|
|
f"Finished populating widget '{widget_name}'.", func_name=func_name
|
|
)
|
|
|
|
except Exception as e:
|
|
log_handler.log_exception(
|
|
f"Error populating text widget '{widget_name}': {e}",
|
|
func_name=func_name,
|
|
)
|
|
# Prova a mostrare errore nel widget stesso
|
|
try:
|
|
if text_widget.winfo_exists():
|
|
text_widget.config(state=tk.NORMAL)
|
|
text_widget.delete("1.0", tk.END)
|
|
text_widget.insert(
|
|
"1.0", f"<Error populating content: {e}>", ("error",)
|
|
)
|
|
text_widget.config(state=tk.DISABLED)
|
|
except Exception:
|
|
pass
|
|
|
|
def _draw_minimap(self):
|
|
"""Draws the minimap overview based on self.diff_map."""
|
|
func_name = "_draw_minimap"
|
|
canvas = self.minimap_canvas
|
|
# Pulisci elementi precedenti
|
|
canvas.delete("diff_line")
|
|
canvas.delete("viewport_indicator")
|
|
|
|
num_lines = len(self.diff_map)
|
|
if num_lines == 0:
|
|
return
|
|
|
|
# Assicura dimensioni valide
|
|
canvas.update_idletasks()
|
|
canvas_width = canvas.winfo_width()
|
|
canvas_height = canvas.winfo_height()
|
|
if canvas_width <= 1 or canvas_height <= 1:
|
|
return
|
|
|
|
log_handler.log_debug(
|
|
f"Drawing minimap ({canvas_width}x{canvas_height}) for {num_lines} lines.",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Calcola altezza linea e disegna rettangoli
|
|
exact_line_height = float(canvas_height) / num_lines
|
|
accumulated_height = 0.0
|
|
for i, diff_type in enumerate(self.diff_map):
|
|
y_start = round(accumulated_height)
|
|
accumulated_height += exact_line_height
|
|
y_end = round(accumulated_height)
|
|
if y_end <= y_start:
|
|
y_end = y_start + 1
|
|
if i == num_lines - 1:
|
|
y_end = canvas_height # Forza ultimo pixel
|
|
|
|
# Colori: 0=uguale(grigio), 1=rimosso/mod(rosso), 2=aggiunto/mod(verde)
|
|
color = "#F0F0F0" # Default grigio
|
|
if diff_type == 1:
|
|
color = "#FFD0D0" # Rosso chiaro
|
|
elif diff_type == 2:
|
|
color = "#D0FFD0" # Verde chiaro
|
|
|
|
canvas.create_rectangle(
|
|
0,
|
|
y_start,
|
|
canvas_width,
|
|
y_end,
|
|
fill=color,
|
|
outline="",
|
|
tags="diff_line",
|
|
)
|
|
|
|
# Disegna indicatore viewport
|
|
self._update_minimap_viewport()
|
|
log_handler.log_debug("Minimap drawing complete.", func_name=func_name)
|
|
|
|
def _hide_loading_message(self):
|
|
"""Removes the loading label from view."""
|
|
if self.loading_label and self.loading_label.winfo_exists():
|
|
try:
|
|
self.loading_label.place_forget()
|
|
log_handler.log_debug(
|
|
"Loading message hidden.", func_name="_hide_loading_message"
|
|
)
|
|
except Exception as e:
|
|
log_handler.log_error(
|
|
f"Failed to hide loading label: {e}",
|
|
func_name="_hide_loading_message",
|
|
)
|
|
|
|
def _show_loading_message(self):
|
|
"""Places and lifts the loading label to make it visible."""
|
|
if self.loading_label and self.loading_label.winfo_exists():
|
|
try:
|
|
# Place al centro del main_frame (primo figlio di self)
|
|
container = self.winfo_children()[0] if self.winfo_children() else self
|
|
self.loading_label.place(
|
|
in_=container, relx=0.5, rely=0.5, anchor=tk.CENTER
|
|
)
|
|
self.loading_label.lift()
|
|
log_handler.log_debug(
|
|
"Loading message shown.", func_name="_show_loading_message"
|
|
)
|
|
except Exception as e:
|
|
log_handler.log_error(
|
|
f"Failed to place/lift loading label: {e}",
|
|
func_name="_show_loading_message",
|
|
)
|
|
|
|
# --- Scrolling Logic ---
|
|
def _setup_scrolling(self):
|
|
"""Configures minimap click for navigation."""
|
|
# Associa evento click sinistro sul canvas alla funzione di scroll
|
|
self.minimap_canvas.bind("<Button-1>", self._on_minimap_click)
|
|
|
|
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 showing current view."""
|
|
func_name = "_update_minimap_viewport"
|
|
canvas = self.minimap_canvas
|
|
canvas.delete("viewport_indicator") # Rimuovi indicatore precedente
|
|
try:
|
|
# Ottieni frazioni y visibili da un widget testo (pane1 va bene)
|
|
first_visible_fraction, last_visible_fraction = self.text_pane1.yview()
|
|
except tk.TclError:
|
|
log_handler.log_warning(
|
|
"TclError getting text yview, cannot update minimap viewport.",
|
|
func_name=func_name,
|
|
)
|
|
return
|
|
|
|
# Assicura dimensioni canvas valide
|
|
canvas.update_idletasks()
|
|
canvas_height = canvas.winfo_height()
|
|
canvas_width = canvas.winfo_width()
|
|
if canvas_height <= 1 or canvas_width <= 1:
|
|
return # Canvas non pronto
|
|
|
|
# Calcola coordinate y del rettangolo indicatore
|
|
y_start = first_visible_fraction * canvas_height
|
|
y_end = last_visible_fraction * canvas_height
|
|
if y_end <= y_start:
|
|
y_end = y_start + 1 # Altezza minima 1px
|
|
|
|
# Disegna nuovo rettangolo indicatore
|
|
canvas.create_rectangle(
|
|
1,
|
|
y_start,
|
|
canvas_width - 1,
|
|
y_end, # Margine laterale 1px
|
|
outline="black",
|
|
width=1,
|
|
tags="viewport_indicator",
|
|
)
|
|
# Assicura 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."""
|
|
func_name = "_on_minimap_click"
|
|
if self._scrolling_active:
|
|
return # Ignora click se già in scroll
|
|
self._scrolling_active = True
|
|
try:
|
|
canvas = self.minimap_canvas
|
|
canvas_height = canvas.winfo_height()
|
|
if canvas_height <= 1:
|
|
self._scrolling_active = False
|
|
return
|
|
|
|
# Calcola frazione verticale del click
|
|
target_fraction = max(0.0, min(event.y / canvas_height, 1.0))
|
|
log_handler.log_debug(
|
|
f"Minimap clicked at y={event.y}, fraction={target_fraction:.3f}",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Muovi entrambi i widget di testo alla stessa frazione
|
|
self.text_pane1.yview_moveto(target_fraction)
|
|
self.text_pane2.yview_moveto(target_fraction)
|
|
|
|
# Aggiorna indicatore
|
|
self._update_minimap_viewport()
|
|
finally:
|
|
# Resetta flag dopo breve ritardo
|
|
self.after(50, self._reset_scroll_flag)
|
|
|
|
# --- Minimap Resize Handling ---
|
|
def _on_minimap_resize(self, event):
|
|
"""Called when the minimap canvas is resized. Schedules redraw (debounce)."""
|
|
func_name = "_on_minimap_resize"
|
|
if self._configure_timer_id:
|
|
self.after_cancel(self._configure_timer_id)
|
|
self._configure_timer_id = self.after(150, self._redraw_minimap_on_resize)
|
|
# log_handler.log_debug(f"Scheduled minimap redraw timer: {self._configure_timer_id}", func_name=func_name) # Log opzionale
|
|
|
|
def _redraw_minimap_on_resize(self):
|
|
"""Actually redraws the minimap. Called by the debounced timer."""
|
|
func_name = "_redraw_minimap_on_resize"
|
|
log_handler.log_debug(
|
|
"Executing debounced minimap redraw due to resize.", func_name=func_name
|
|
)
|
|
self._configure_timer_id = None # Resetta ID timer
|
|
self._draw_minimap() # Chiama funzione di disegno
|
|
|
|
|
|
# --- END OF FILE diff_viewer.py ---
|