add diff_viewer into commit tab
This commit is contained in:
parent
2416ab0b2a
commit
b5dbd75999
1536
GitUtility.py
1536
GitUtility.py
File diff suppressed because it is too large
Load Diff
538
diff_viewer.py
Normal file
538
diff_viewer.py
Normal file
@ -0,0 +1,538 @@
|
|||||||
|
# --- 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, ["<Error loading HEAD content>"])
|
||||||
|
self._populate_text(self.text_workdir, ["<Error loading Workdir content>"])
|
||||||
|
# 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("<Configure>", 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"<Error loading HEAD: {e_head}>"]
|
||||||
|
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"<Error reading WD: {fb_e}>"]
|
||||||
|
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"<Error loading Workdir: {e_wd}>"]
|
||||||
|
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 = "<Internal Error: Bad data format>\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("<Button-1>", 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 ---
|
||||||
@ -1101,3 +1101,80 @@ class GitCommands:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(f"Unexpected error in get_matching_gitignore_rule: {e}")
|
self.logger.exception(f"Unexpected error in get_matching_gitignore_rule: {e}")
|
||||||
raise GitCommandError(f"Unexpected check-ignore -v error: {e}", command=command) from e
|
raise GitCommandError(f"Unexpected check-ignore -v error: {e}", command=command) from e
|
||||||
|
|
||||||
|
def get_status_short(self, working_directory):
|
||||||
|
"""
|
||||||
|
Gets the repository status in short format (-z for null termination).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of strings, each representing a changed file
|
||||||
|
and its status (e.g., " M filename", "?? newfile").
|
||||||
|
Returns empty list on error.
|
||||||
|
"""
|
||||||
|
self.logger.debug(f"Getting short status for '{working_directory}'")
|
||||||
|
# -z null terminates filenames, safer for paths with spaces/special chars
|
||||||
|
# --ignored=no : Non mostrare file ignorati (di solito default, ma esplicito)
|
||||||
|
command = ["git", "status", "--short", "-z", "--ignored=no"]
|
||||||
|
try:
|
||||||
|
# L'output di status può essere utile, ma non enorme. Logghiamolo a DEBUG.
|
||||||
|
result = self.log_and_execute(command, working_directory, check=True, log_output_level=logging.DEBUG)
|
||||||
|
# Split by null terminator and filter out empty strings
|
||||||
|
status_lines = [line for line in result.stdout.split('\0') if line]
|
||||||
|
self.logger.info(f"Git status check returned {len(status_lines)} changed/untracked items.")
|
||||||
|
return status_lines
|
||||||
|
except (GitCommandError, ValueError) as e:
|
||||||
|
self.logger.error(f"Failed to get git status: {e}")
|
||||||
|
return [] # Ritorna lista vuota in caso di errore
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.exception(f"Unexpected error getting git status: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_file_content_from_ref(self, working_directory, file_path, ref="HEAD"):
|
||||||
|
"""
|
||||||
|
Retrieves the content of a file from a specific Git reference (e.g., HEAD, index).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
working_directory (str): Path to the Git repository.
|
||||||
|
file_path (str): Relative path to the file within the repo.
|
||||||
|
ref (str): The Git reference (e.g., "HEAD", "master", commit hash, or ":" for index).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str or None: The file content as a string, or None if the file
|
||||||
|
doesn't exist in that ref or an error occurs.
|
||||||
|
"""
|
||||||
|
# Normalizza i separatori per Git (usa sempre '/')
|
||||||
|
git_style_path = file_path.replace(os.path.sep, '/')
|
||||||
|
ref_prefix = f"{ref}:" if ref else ":" # Usa ":" per l'index se ref è vuoto o None
|
||||||
|
|
||||||
|
# Costruisci l'argomento ref:path
|
||||||
|
ref_path_arg = f"{ref_prefix}{git_style_path}"
|
||||||
|
|
||||||
|
self.logger.debug(f"Getting file content for '{ref_path_arg}' in '{working_directory}'")
|
||||||
|
command = ["git", "show", ref_path_arg]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# check=False perché un file non trovato in HEAD/index non è un errore fatale,
|
||||||
|
# significa solo che è nuovo o cancellato. L'output va a DEBUG.
|
||||||
|
result = self.log_and_execute(command, working_directory, check=False, log_output_level=logging.DEBUG)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Successo, ritorna il contenuto (stdout)
|
||||||
|
return result.stdout
|
||||||
|
elif result.returncode == 128 and ("exists on disk, but not in" in result.stderr or \
|
||||||
|
"does not exist in" in result.stderr or \
|
||||||
|
"did not match any file" in result.stderr):
|
||||||
|
# Codice 128 con errore specifico -> file non trovato nel ref
|
||||||
|
self.logger.debug(f"File '{git_style_path}' not found in ref '{ref}'.")
|
||||||
|
return None # Ritorna None per indicare file non esistente nel ref
|
||||||
|
else:
|
||||||
|
# Altro errore di git show
|
||||||
|
self.logger.error(f"git show command failed for '{ref_path_arg}' with code {result.returncode}. Stderr: {result.stderr}")
|
||||||
|
return None # Ritorna None per indicare errore generico
|
||||||
|
|
||||||
|
except (GitCommandError, ValueError) as e:
|
||||||
|
# Questo catturerebbe errori nell'esecuzione del comando stesso (raro se check=False)
|
||||||
|
self.logger.error(f"Error executing git show for '{ref_path_arg}': {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.exception(f"Unexpected error in get_file_content_from_ref for '{ref_path_arg}': {e}")
|
||||||
|
return None
|
||||||
130
gui.py
130
gui.py
@ -381,6 +381,8 @@ class MainFrame(ttk.Frame):
|
|||||||
refresh_branches_cb,
|
refresh_branches_cb,
|
||||||
checkout_branch_cb,
|
checkout_branch_cb,
|
||||||
create_branch_cb,
|
create_branch_cb,
|
||||||
|
refresh_changed_files_cb, # <-- NUOVO PARAMETRO
|
||||||
|
open_diff_viewer_cb,
|
||||||
):
|
):
|
||||||
"""Initializes the MainFrame with tabs."""
|
"""Initializes the MainFrame with tabs."""
|
||||||
super().__init__(master)
|
super().__init__(master)
|
||||||
@ -405,6 +407,8 @@ class MainFrame(ttk.Frame):
|
|||||||
self.refresh_branches_callback = refresh_branches_cb
|
self.refresh_branches_callback = refresh_branches_cb
|
||||||
self.checkout_branch_callback = checkout_branch_cb
|
self.checkout_branch_callback = checkout_branch_cb
|
||||||
self.create_branch_callback = create_branch_cb
|
self.create_branch_callback = create_branch_cb
|
||||||
|
self.open_diff_viewer_callback = open_diff_viewer_cb
|
||||||
|
|
||||||
|
|
||||||
# Store instances and initial data
|
# Store instances and initial data
|
||||||
self.config_manager = config_manager_instance
|
self.config_manager = config_manager_instance
|
||||||
@ -793,56 +797,88 @@ class MainFrame(ttk.Frame):
|
|||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _create_commit_tab(self):
|
def _create_commit_tab(self):
|
||||||
"""Creates the frame for the 'Commit' tab."""
|
"""Creates the frame for the 'Commit' tab with changed files list."""
|
||||||
# (No changes needed here for this modification)
|
|
||||||
frame = ttk.Frame(self.notebook, padding=(10, 10))
|
frame = ttk.Frame(self.notebook, padding=(10, 10))
|
||||||
frame.rowconfigure(2, weight=1)
|
# Riduci peso riga messaggio, aumenta peso riga lista file
|
||||||
frame.columnconfigure(0, weight=1)
|
frame.rowconfigure(2, weight=0) # Riga messaggio commit non si espande molto
|
||||||
|
frame.rowconfigure(4, weight=1) # Riga lista file si espande
|
||||||
|
frame.columnconfigure(0, weight=1) # Colonna principale si espande
|
||||||
|
|
||||||
|
# --- Sezione Autocommit --- (Invariata)
|
||||||
self.autocommit_checkbox = ttk.Checkbutton(
|
self.autocommit_checkbox = ttk.Checkbutton(
|
||||||
frame,
|
frame, # <<< DEVE ESSERE 'frame' QUI
|
||||||
text="Enable Autocommit before 'Create Bundle' action",
|
text="Enable Autocommit before 'Create Bundle' action",
|
||||||
variable=self.autocommit_var,
|
variable=self.autocommit_var,
|
||||||
state=tk.DISABLED,
|
state=tk.DISABLED,
|
||||||
)
|
)
|
||||||
self.autocommit_checkbox.grid(
|
self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, sticky="w", padx=5, pady=(0, 5))
|
||||||
row=0, column=0, columnspan=2, sticky="w", padx=5, pady=(0, 10)
|
self.create_tooltip(self.autocommit_checkbox, "...")
|
||||||
)
|
|
||||||
self.create_tooltip(
|
# --- Sezione Messaggio Commit --- (Altezza ridotta)
|
||||||
self.autocommit_checkbox,
|
ttk.Label(frame, text="Commit Message:").grid(row=1, column=0, columnspan=3, sticky="w", padx=5)
|
||||||
"If checked, use message below to commit before Create Bundle.",
|
|
||||||
)
|
|
||||||
ttk.Label(frame, text="Commit Message:").grid(
|
|
||||||
row=1, column=0, columnspan=2, sticky="w", padx=5
|
|
||||||
)
|
|
||||||
self.commit_message_text = scrolledtext.ScrolledText(
|
self.commit_message_text = scrolledtext.ScrolledText(
|
||||||
frame,
|
frame,
|
||||||
height=7,
|
height=3, # <<< Altezza ridotta
|
||||||
width=60,
|
width=60, wrap=tk.WORD, font=("Segoe UI", 9),
|
||||||
wrap=tk.WORD,
|
state=tk.DISABLED, undo=True, padx=5, pady=5,
|
||||||
font=("Segoe UI", 9),
|
|
||||||
state=tk.DISABLED,
|
|
||||||
undo=True,
|
|
||||||
padx=5,
|
|
||||||
pady=5,
|
|
||||||
)
|
)
|
||||||
self.commit_message_text.grid(
|
self.commit_message_text.grid(row=2, column=0, columnspan=3, sticky="ew", padx=5, pady=(0, 5))
|
||||||
row=2, column=0, columnspan=2, sticky="nsew", padx=5, pady=(0, 5)
|
self.create_tooltip(self.commit_message_text, "Enter commit message...")
|
||||||
|
|
||||||
|
# --- MODIFICA: Aggiunta Sezione Changed Files ---
|
||||||
|
changes_frame = ttk.LabelFrame(frame, text="Changes to be Committed / Staged", padding=(10, 5))
|
||||||
|
changes_frame.grid(row=3, column=0, columnspan=3, sticky='nsew', padx=5, pady=(5,5))
|
||||||
|
changes_frame.rowconfigure(0, weight=1) # Lista si espande
|
||||||
|
changes_frame.columnconfigure(0, weight=1) # Lista si espande
|
||||||
|
|
||||||
|
# Lista File Modificati
|
||||||
|
list_sub_frame = ttk.Frame(changes_frame) # Frame per listbox e scrollbar
|
||||||
|
list_sub_frame.grid(row=0, column=0, sticky='nsew', pady=(0, 5))
|
||||||
|
list_sub_frame.rowconfigure(0, weight=1)
|
||||||
|
list_sub_frame.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.changed_files_listbox = tk.Listbox(
|
||||||
|
list_sub_frame,
|
||||||
|
height=8, # Altezza iniziale, ma si espanderà con la riga 4 di 'frame'
|
||||||
|
exportselection=False,
|
||||||
|
selectmode=tk.SINGLE,
|
||||||
|
font=("Consolas", 9), # Font Monospace utile per status
|
||||||
)
|
)
|
||||||
self.create_tooltip(
|
self.changed_files_listbox.grid(row=0, column=0, sticky="nsew")
|
||||||
self.commit_message_text, "Enter commit message for manual or autocommit."
|
# Associa doppio click all'apertura del diff viewer (verrà collegato in GitUtility)
|
||||||
|
self.changed_files_listbox.bind("<Double-Button-1>", self._on_changed_file_double_click)
|
||||||
|
|
||||||
|
scrollbar_list = ttk.Scrollbar(
|
||||||
|
list_sub_frame, orient=tk.VERTICAL, command=self.changed_files_listbox.yview
|
||||||
)
|
)
|
||||||
|
scrollbar_list.grid(row=0, column=1, sticky="ns")
|
||||||
|
self.changed_files_listbox.config(yscrollcommand=scrollbar_list.set)
|
||||||
|
self.create_tooltip(self.changed_files_listbox, "Double-click a file to view changes (diff).")
|
||||||
|
|
||||||
|
# Pulsante Refresh Lista File
|
||||||
|
self.refresh_changes_button = ttk.Button(
|
||||||
|
changes_frame,
|
||||||
|
text="Refresh List",
|
||||||
|
# Collegato a callback in GitUtility
|
||||||
|
# command=self.refresh_changed_files_callback
|
||||||
|
state=tk.DISABLED # Abilitato quando repo è pronto
|
||||||
|
)
|
||||||
|
self.refresh_changes_button.grid(row=1, column=0, sticky="w", padx=(0, 5), pady=(5, 0))
|
||||||
|
self.create_tooltip(self.refresh_changes_button, "Refresh the list of changed files.")
|
||||||
|
# --- FINE MODIFICA ---
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pulsante Commit Manuale --- (Spostato sotto)
|
||||||
self.commit_button = ttk.Button(
|
self.commit_button = ttk.Button(
|
||||||
frame,
|
frame, # Ora nel frame principale
|
||||||
text="Commit All Changes Manually",
|
text="Commit All Changes Manually",
|
||||||
command=self.commit_changes_callback,
|
# command=self.commit_changes_callback
|
||||||
state=tk.DISABLED,
|
state=tk.DISABLED,
|
||||||
)
|
)
|
||||||
self.commit_button.grid(
|
# Messo in basso a destra
|
||||||
row=3, column=0, columnspan=2, sticky="e", padx=5, pady=5
|
self.commit_button.grid(row=4, column=2, sticky="se", padx=5, pady=5)
|
||||||
)
|
self.create_tooltip(self.commit_button, "Stage ALL changes and commit with the message above.")
|
||||||
self.create_tooltip(
|
|
||||||
self.commit_button, "Stage ALL changes and commit with the message above."
|
|
||||||
)
|
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _create_tags_tab(self):
|
def _create_tags_tab(self):
|
||||||
@ -1306,3 +1342,27 @@ class MainFrame(ttk.Frame):
|
|||||||
|
|
||||||
def update_tooltip(self, widget, text):
|
def update_tooltip(self, widget, text):
|
||||||
self.create_tooltip(widget, text) # Recreate is simplest
|
self.create_tooltip(widget, text) # Recreate is simplest
|
||||||
|
|
||||||
|
def update_changed_files_list(self, files_status_list):
|
||||||
|
"""Clears and populates the changed files listbox."""
|
||||||
|
if not hasattr(self, "changed_files_listbox"): return
|
||||||
|
self.changed_files_listbox.config(state=tk.NORMAL)
|
||||||
|
self.changed_files_listbox.delete(0, tk.END)
|
||||||
|
if files_status_list:
|
||||||
|
# Potresti voler formattare meglio lo stato qui
|
||||||
|
for status_line in files_status_list:
|
||||||
|
self.changed_files_listbox.insert(tk.END, status_line)
|
||||||
|
else:
|
||||||
|
self.changed_files_listbox.insert(tk.END, "(No changes detected)")
|
||||||
|
|
||||||
|
# Questo chiamerà la funzione vera in GitUtility
|
||||||
|
def _on_changed_file_double_click(self, event):
|
||||||
|
# Recupera l'indice selezionato dalla listbox
|
||||||
|
widget = event.widget
|
||||||
|
selection = widget.curselection()
|
||||||
|
if selection:
|
||||||
|
index = selection[0]
|
||||||
|
file_status_line = widget.get(index)
|
||||||
|
# Chiama il metodo del controller (verrà impostato in __init__ di MainFrame)
|
||||||
|
if hasattr(self, 'open_diff_viewer_callback') and callable(self.open_diff_viewer_callback):
|
||||||
|
self.open_diff_viewer_callback(file_status_line)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user