SXXXXXXX_CodeBridge/codebridge/gui/diff_viewer.py
2025-12-23 15:17:46 +01:00

665 lines
30 KiB
Python

import tkinter as tk
from tkinter import ttk, messagebox
import tkinter.font as tkfont
import difflib
import os
import shutil
class DiffViewer(tk.Toplevel):
"""
Side-by-side diff viewer for comparing two files.
"""
def __init__(self, parent, source_path: str, dest_path: str, relative_path: str):
super().__init__(parent)
self.source_path = source_path
self.dest_path = dest_path
self.relative_path = relative_path
self.title(f"Diff - {relative_path}")
self.geometry("1200x800")
self._setup_ui()
self._load_and_compare()
def _setup_ui(self) -> None:
"""
Initializes the side-by-side layout and buttons.
"""
# Top toolbar: navigation / refresh / save
toolbar = ttk.LabelFrame(self, text="Actions", padding=6)
toolbar.pack(fill="x", side="top", padx=5, pady=3)
ttk.Button(toolbar, text="➡ Next Diff", command=self._next_diff).pack(side="left", padx=4)
ttk.Button(toolbar, text="🔁 Refresh", command=self._refresh_diff).pack(side="left", padx=4)
ttk.Button(toolbar, text="💾 Save", command=self._save_changes).pack(side="left", padx=4)
# status label to indicate when the two panes are identical (styled)
style = ttk.Style(self)
try:
bold_font = tkfont.Font(self, family="TkDefaultFont", weight="bold")
except Exception:
bold_font = None
style.configure('Identical.TLabel', foreground='#ffffff', background='#2e8b57')
if bold_font:
style.configure('Identical.TLabel', font=bold_font)
self._identical_label = ttk.Label(toolbar, text="", style='Identical.TLabel')
self._identical_label.pack(side="right", padx=8, pady=2)
# Main diff container: left text, minimap, right text, then scrollbar
main_frame = ttk.Frame(self)
main_frame.pack(fill="both", expand=True, padx=5, pady=5)
# Use grid inside main_frame so left and right panes can share space equally
main_frame.grid_rowconfigure(0, weight=1)
main_frame.grid_columnconfigure(0, weight=1)
main_frame.grid_columnconfigure(1, weight=0)
main_frame.grid_columnconfigure(2, weight=1)
# Left: Source frame with path and copy-buttons
left_frame = ttk.LabelFrame(main_frame, text="Source", padding=4)
left_frame.grid(row=0, column=0, sticky="nsew")
# use grid inside left_frame so horizontal scrollbar spans text width
left_frame.grid_rowconfigure(2, weight=1)
left_frame.grid_columnconfigure(0, weight=1)
src_label = ttk.Label(left_frame, text=f"{self.source_path}", anchor="w")
src_label.grid(row=0, column=0, columnspan=2, sticky="ew", padx=2, pady=(2, 4))
src_btns = ttk.Frame(left_frame)
src_btns.grid(row=1, column=0, columnspan=2, sticky="ew", padx=2, pady=(0,4))
ttk.Button(src_btns, text="Copy to Right →", command=lambda: self._copy_all('left','right')).pack(side="left", padx=3)
ttk.Button(src_btns, text="Copy Selection to Right →", command=lambda: self._copy_selection('left','right')).pack(side="left", padx=3)
# Text area with vertical scrollbar
self.left_text = tk.Text(left_frame, wrap="none", undo=True)
self.left_text.grid(row=2, column=0, sticky="nsew")
self.left_scroll = ttk.Scrollbar(left_frame, orient="vertical", command=lambda *a: self._sync_scroll_from('left', *a))
self.left_scroll.grid(row=2, column=1, sticky="ns")
# horizontal scrollbar for left text (wire to sync handler)
self.left_hscroll = ttk.Scrollbar(left_frame, orient="horizontal", command=lambda *a: self._sync_xscroll_from('left', *a))
self.left_hscroll.grid(row=3, column=0, columnspan=2, sticky="ew")
self.left_text.config(xscrollcommand=lambda *a: self._on_xscroll('left', *a))
# Minimap between the two text widgets
# Center: Map frame
center_pane = ttk.LabelFrame(main_frame, text="Map", padding=4)
center_pane.grid(row=0, column=1, sticky="ns")
self.minimap = tk.Canvas(center_pane, width=60, bg="#f0f0f0", highlightthickness=1, highlightbackground='black')
self.minimap.pack(fill="both", expand=True, padx=4, pady=2)
# Right: Destination frame with path and copy-buttons
right_frame = ttk.LabelFrame(main_frame, text="Destination", padding=4)
right_frame.grid(row=0, column=2, sticky="nsew")
# use grid inside right_frame so horizontal scrollbar spans text width
right_frame.grid_rowconfigure(2, weight=1)
right_frame.grid_columnconfigure(0, weight=1)
dest_label = ttk.Label(right_frame, text=f"{self.dest_path}", anchor="w")
dest_label.grid(row=0, column=0, columnspan=2, sticky="ew", padx=2, pady=(2, 4))
dst_btns = ttk.Frame(right_frame)
dst_btns.grid(row=1, column=0, columnspan=2, sticky="ew", padx=2, pady=(0,4))
ttk.Button(dst_btns, text="← Copy to Left", command=lambda: self._copy_all('right','left')).pack(side="left", padx=3)
ttk.Button(dst_btns, text="← Copy Selection to Left", command=lambda: self._copy_selection('right','left')).pack(side="left", padx=3)
self.right_text = tk.Text(right_frame, wrap="none", undo=True)
self.right_text.grid(row=2, column=0, sticky="nsew")
self.right_scroll = ttk.Scrollbar(right_frame, orient="vertical", command=lambda *a: self._sync_scroll_from('right', *a))
self.right_scroll.grid(row=2, column=1, sticky="ns")
# horizontal scrollbar for right text (wire to sync handler)
self.right_hscroll = ttk.Scrollbar(right_frame, orient="horizontal", command=lambda *a: self._sync_xscroll_from('right', *a))
self.right_hscroll.grid(row=3, column=0, columnspan=2, sticky="ew")
self.right_text.config(xscrollcommand=lambda *a: self._on_xscroll('right', *a))
# Scrollbar for both text widgets (rightmost)
self.v_scroll = ttk.Scrollbar(main_frame, orient="vertical", command=self._sync_scroll)
self.v_scroll.grid(row=0, column=3, sticky="ns")
# Use custom yscrollcommand so we can update minimap viewport
self.left_text.config(yscrollcommand=lambda *a: self._on_yscroll('left', *a))
self.right_text.config(yscrollcommand=lambda *a: self._on_yscroll('right', *a))
# Colors for diff (left: source, right: destination)
self.left_text.tag_config("added", background="#d6ffd6")
self.left_text.tag_config("changed", background="#fff3bf")
# inline changed region (more accentuated)
self.left_text.tag_config("changed_inline", background="#ffb366")
self.right_text.tag_config("removed", background="#ffd6d6")
self.right_text.tag_config("changed", background="#fff3bf")
self.right_text.tag_config("changed_inline", background="#ffb366")
# Bind mousewheel to synchronize scrolling between both panes
self.left_text.bind("<MouseWheel>", self._on_mousewheel)
self.right_text.bind("<MouseWheel>", self._on_mousewheel)
# For X11 systems
self.left_text.bind("<Button-4>", self._on_mousewheel)
self.left_text.bind("<Button-5>", self._on_mousewheel)
self.right_text.bind("<Button-4>", self._on_mousewheel)
self.right_text.bind("<Button-5>", self._on_mousewheel)
# Minimap interactions
self.minimap.bind("<Button-1>", self._on_minimap_click)
# rebuild minimap when resized
self.minimap.bind("<Configure>", lambda e: self._build_minimap())
self._minimap_marks = []
# track encodings and dirty state
self._left_encoding = 'utf-8'
self._right_encoding = 'utf-8'
self._left_dirty = False
self._right_dirty = False
# mark edits
self.left_text.bind('<KeyRelease>', lambda e: self._mark_dirty('left'))
self.right_text.bind('<KeyRelease>', lambda e: self._mark_dirty('right'))
# intercept close
self.protocol('WM_DELETE_WINDOW', self._on_close)
def _sync_scroll(self, *args) -> None:
"""
Synchronizes vertical scrolling between both text widgets.
"""
self.left_text.yview(*args)
self.right_text.yview(*args)
# update left/right scrollbar positions
try:
self.left_scroll.set(*self.left_text.yview())
except Exception:
pass
try:
self.right_scroll.set(*self.right_text.yview())
except Exception:
pass
# update minimap viewport
self._update_minimap_viewport()
def _on_yscroll(self, *args) -> None:
"""Callback used as yscrollcommand for text widgets to update scrollbar and minimap."""
side = args[0] if args else None
# args may be ('fraction1','fraction2') or ('moveto', frac)
try:
if side == '0.0':
pass
except Exception:
pass
# update both scrollbars positions from both text widgets
try:
self.left_scroll.set(*self.left_text.yview())
except Exception:
pass
try:
self.right_scroll.set(*self.right_text.yview())
except Exception:
pass
self._update_minimap_viewport()
def _sync_scroll_from(self, which: str, *args) -> None:
"""Scrollbar command handler for left/right scrollbars: forward to both text widgets."""
try:
# args forwarded to yview
self.left_text.yview(*args)
self.right_text.yview(*args)
# update both scrollbars
self.left_scroll.set(*self.left_text.yview())
self.right_scroll.set(*self.right_text.yview())
self._update_minimap_viewport()
except Exception:
pass
def _on_xscroll(self, which: str, *args) -> None:
"""Callback used as xscrollcommand for text widgets to update horizontal scrollbars and sync other pane."""
# prevent recursion
if getattr(self, '_xsync_in_progress', False):
return
try:
self._xsync_in_progress = True
if which == 'left':
try:
self.left_hscroll.set(*args)
except Exception:
pass
# args are typically (first, last) fractions
try:
frac = float(args[0]) if args else 0.0
self.right_text.xview_moveto(frac)
except Exception:
pass
else:
try:
self.right_hscroll.set(*args)
except Exception:
pass
try:
frac = float(args[0]) if args else 0.0
self.left_text.xview_moveto(frac)
except Exception:
pass
finally:
self._xsync_in_progress = False
def _sync_xscroll_from(self, which: str, *args) -> None:
"""Scrollbar command handler for horizontal scrollbars: forward to both text widgets."""
try:
# args can be ('moveto', fraction) or ('scroll', number, 'units'/'pages')
if args and args[0] == 'moveto':
frac = float(args[1])
self.left_text.xview_moveto(frac)
self.right_text.xview_moveto(frac)
else:
# delegate to xview which handles scroll units/pages
try:
self.left_text.xview(*args)
self.right_text.xview(*args)
except Exception:
pass
# update scrollbar thumbs
try:
self.left_hscroll.set(*self.left_text.xview())
except Exception:
pass
try:
self.right_hscroll.set(*self.right_text.xview())
except Exception:
pass
except Exception:
pass
def _on_mousewheel(self, event) -> str:
"""Handle mouse wheel events to scroll both panes together."""
try:
if event.num == 5 or event.delta < 0:
delta = 1
else:
delta = -1
except Exception:
# fallback
delta = int(-1 * (event.delta / 120)) if hasattr(event, 'delta') else 0
try:
self.left_text.yview_scroll(delta, "units")
self.right_text.yview_scroll(delta, "units")
self._update_minimap_viewport()
except Exception:
pass
return "break"
def _load_and_compare(self) -> None:
"""
Reads files and highlights differences.
"""
try:
source_lines, enc = self._read_file_with_fallback(self.source_path)
self._left_encoding = enc or 'utf-8'
# Handle case where destination file might not exist (Added files)
dest_lines = []
if os.path.exists(self.dest_path):
dest_lines, enc2 = self._read_file_with_fallback(self.dest_path)
self._right_encoding = enc2 or 'utf-8'
# store lines for display rendering
self._src_lines = source_lines
self._dest_lines = dest_lines
self._display_diff()
except Exception as e:
messagebox.showerror("Error", f"Could not read files: {e}", parent=self)
self.destroy()
def _read_file_with_fallback(self, path: str):
"""Read a file and try common encodings if UTF-8 fails.
Returns a list of lines (with line endings) or raises an Exception
if the file appears to be binary.
"""
if not os.path.exists(path):
return []
# Read raw bytes first to detect NULs (binary files)
with open(path, 'rb') as bf:
data = bf.read()
if b'\x00' in data:
raise Exception('File appears to be binary (contains NUL bytes)')
# Try decodings in order
for enc in ('utf-8', 'cp1252', 'latin-1'):
try:
text = data.decode(enc)
return text.splitlines(keepends=True), enc
except UnicodeDecodeError:
continue
# As a last resort, decode with replacement to avoid crashing
text = data.decode('utf-8', errors='replace')
return text.splitlines(keepends=True), 'utf-8'
def _display_diff(self) -> None:
"""
Show the full source and destination contents and tag differing ranges.
Uses SequenceMatcher opcodes to compute changed/added/removed ranges so
we do not need to insert placeholder blank lines that accumulate on refresh.
"""
# ensure editable while displaying
self.left_text.config(state="normal")
self.right_text.config(state="normal")
# clear widgets
self.left_text.delete('1.0', 'end')
self.right_text.delete('1.0', 'end')
# source (left) and dest (right) lines
src_lines = getattr(self, '_src_lines', [])
dest_lines = getattr(self, '_dest_lines', [])
# insert full contents as-is
self.left_text.insert('1.0', ''.join(src_lines))
self.right_text.insert('1.0', ''.join(dest_lines))
# prepare status arrays for minimap
left_lines = ['unchanged'] * len(src_lines)
right_lines = ['unchanged'] * len(dest_lines)
# compute opcodes with dest as a, source as b
matcher = difflib.SequenceMatcher(None, dest_lines, src_lines)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
continue
if tag == 'replace':
# mark changed ranges on both sides
if j2 > j1:
start = f"{j1+1}.0"
end = f"{j2+1}.0"
self.left_text.tag_add('changed', start, end)
for k in range(j1, j2):
left_lines[k] = 'changed'
if i2 > i1:
start = f"{i1+1}.0"
end = f"{i2+1}.0"
self.right_text.tag_add('changed', start, end)
for k in range(i1, i2):
right_lines[k] = 'changed'
# Attempt intraline/highlight of exact differing character ranges
try:
# pair up lines in the replace block where possible
left_block_len = j2 - j1
right_block_len = i2 - i1
pairs = min(left_block_len, right_block_len)
for offset in range(pairs):
lidx = j1 + offset
ridx = i1 + offset
# get raw line text without trailing newline for character offsets
try:
left_line = src_lines[lidx].rstrip('\n')
except Exception:
left_line = ''
try:
right_line = dest_lines[ridx].rstrip('\n')
except Exception:
right_line = ''
# if either line is empty, skip detailed matching
if not left_line and not right_line:
continue
sm = difflib.SequenceMatcher(None, left_line, right_line)
for ctag, a1, a2, b1, b2 in sm.get_opcodes():
if ctag == 'equal':
continue
# apply inline tags: left side chars a1..a2, right side b1..b2
# left
if a2 > a1:
try:
start_idx = f"{lidx+1}.{a1}"
end_idx = f"{lidx+1}.{a2}"
self.left_text.tag_add('changed_inline', start_idx, end_idx)
except Exception:
pass
# right
if b2 > b1:
try:
start_idx = f"{ridx+1}.{b1}"
end_idx = f"{ridx+1}.{b2}"
self.right_text.tag_add('changed_inline', start_idx, end_idx)
except Exception:
pass
except Exception:
pass
elif tag == 'delete':
# lines present in dest (right) but deleted from source -> removed on right
if i2 > i1:
start = f"{i1+1}.0"
end = f"{i2+1}.0"
self.right_text.tag_add('removed', start, end)
for k in range(i1, i2):
right_lines[k] = 'removed'
elif tag == 'insert':
# lines present in source (left) but not in dest -> added on left
if j2 > j1:
start = f"{j1+1}.0"
end = f"{j2+1}.0"
self.left_text.tag_add('added', start, end)
for k in range(j1, j2):
left_lines[k] = 'added'
# store for minimap rendering
self._left_lines_status = left_lines
self._right_lines_status = right_lines
# update identical-files indicator
try:
if src_lines == dest_lines:
# show a visible green badge with checkmark
try:
self._identical_label.config(text="✅ Files identical")
except Exception:
self._identical_label.config(text="Files identical")
else:
self._identical_label.config(text="")
except Exception:
pass
# build minimap markers
self._build_minimap()
def _build_minimap(self) -> None:
"""Draw a simple minimap showing added/removed/changed lines."""
try:
self.minimap.delete("all")
height = max(100, self.minimap.winfo_height())
width = max(30, self.minimap.winfo_width())
# choose a unified total based on the maximum number of lines
left_status = getattr(self, '_left_lines_status', [])
right_status = getattr(self, '_right_lines_status', [])
left_len = len(left_status)
right_len = len(right_status)
total = max(left_len, right_len, 1)
# draw markers: additions on left (green), deletions on right (red), changes (yellow)
for i, status in enumerate(left_status):
if status == 'added':
y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#4dff4d', outline='')
elif status == 'changed':
y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ffd966', outline='')
for i, status in enumerate(right_status):
if status == 'removed':
y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ff4d4d', outline='')
elif status == 'changed':
y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ffd966', outline='')
# viewport rectangle
self._update_minimap_viewport()
except Exception:
pass
def _update_minimap_viewport(self) -> None:
try:
self.minimap.delete('viewport')
height = max(100, self.minimap.winfo_height())
total = max(len(getattr(self, '_right_lines_status', [])), 1)
first, last = self.right_text.yview()
y1 = int(first * height)
y2 = int(last * height)
self.minimap.create_rectangle(1, y1, self.minimap.winfo_width()-1, y2, outline='#3333ff', tag='viewport')
except Exception:
pass
def _on_minimap_click(self, event) -> None:
try:
height = max(1, self.minimap.winfo_height())
frac = event.y / height
self.right_text.yview_moveto(frac)
self.left_text.yview_moveto(frac)
self._update_minimap_viewport()
except Exception:
pass
def _copy_source_to_dest(self) -> None:
"""
Overwrites the destination file with the source file content.
"""
confirm = messagebox.askyesno("Confirm", "Overwrite destination file with source content?", parent=self)
if confirm:
try:
os.makedirs(os.path.dirname(self.dest_path), exist_ok=True)
shutil.copy2(self.source_path, self.dest_path)
messagebox.showinfo("Success", "File copied successfully.", parent=self)
self.destroy()
except Exception as e:
messagebox.showerror("Error", f"Failed to copy file: {e}", parent=self)
def _mark_dirty(self, side: str) -> None:
if side == 'left':
self._left_dirty = True
else:
self._right_dirty = True
def _next_diff(self) -> None:
"""Scroll to the next diff marker in right (added) or left (removed)."""
try:
# top visible lines for each pane
right_top = int(self.right_text.index('@0,0').split('.')[0])
left_top = int(self.left_text.index('@0,0').split('.')[0])
def find_next_in(widget, tags, start_line):
# start searching from the line after the top visible one to avoid hitting the same hunk repeatedly
try:
search_start = max(1, int(start_line) + 1)
except Exception:
search_start = 1
start_index = f"{search_start}.0"
candidates = []
for tag in tags:
try:
rng = widget.tag_nextrange(tag, start_index)
except Exception:
rng = None
if rng:
idx = widget.index(rng[0])
line = int(str(idx).split('.')[0])
candidates.append((line, widget, idx))
if candidates:
return min(candidates, key=lambda t: t[0])
return None
# search for next diff in both panes, using each pane's visible top as reference
right_match = find_next_in(self.right_text, ['removed', 'changed'], right_top)
left_match = find_next_in(self.left_text, ['added', 'changed'], left_top)
candidates = [m for m in (right_match, left_match) if m]
target = None
if candidates:
target = min(candidates, key=lambda t: t[0])
else:
# wrap: search from start of file
right_wrap = find_next_in(self.right_text, ['removed', 'changed'], 1)
left_wrap = find_next_in(self.left_text, ['added', 'changed'], 1)
candidates = [m for m in (right_wrap, left_wrap) if m]
if candidates:
target = min(candidates, key=lambda t: t[0])
if target:
line, widget, idx = target
# compute fraction based on the widget that contains the target to keep centering logical
try:
if widget is self.right_text:
total_lines = max(1, int(self.right_text.index('end-1c').split('.')[0]))
else:
total_lines = max(1, int(self.left_text.index('end-1c').split('.')[0]))
frac = (line - 1) / max(total_lines - 1, 1)
except Exception:
frac = 0.0
# move both views to the same fractional position to keep panes aligned
try:
self.right_text.yview_moveto(frac)
self.left_text.yview_moveto(frac)
except Exception:
pass
try:
widget.focus_set()
widget.mark_set('insert', idx)
except Exception:
pass
self._update_minimap_viewport()
except Exception:
pass
def _copy_selection(self, src: str, dst: str) -> None:
"""Copy selected text from src pane to dst pane (with confirmation)."""
src_text = self.right_text if src == 'right' else self.left_text
dst_text = self.left_text if dst == 'left' else self.right_text
ranges = src_text.tag_ranges('sel')
if not ranges:
messagebox.showinfo('No Selection', 'Select the lines to copy first.', parent=self)
return
start_obj, end_obj = ranges[0], ranges[1]
start = src_text.index(start_obj)
end = src_text.index(end_obj)
sel_text = src_text.get(start, end)
if not sel_text:
messagebox.showinfo('No Selection', 'Selected region is empty.', parent=self)
return
if not messagebox.askyesno('Confirm copy', f'Copy selection from {src} to {dst}?', parent=self):
return
# insert at corresponding line number
line = int(str(start).split('.')[0])
insert_index = f"{line}.0"
dst_text.insert(insert_index, sel_text)
self._mark_dirty(dst)
self._refresh_diff()
def _copy_all(self, src: str, dst: str) -> None:
src_text = self.right_text if src == 'right' else self.left_text
dst_text = self.left_text if dst == 'left' else self.right_text
if not messagebox.askyesno('Confirm copy all', f'Copy entire content from {src} to {dst}?', parent=self):
return
# use end-1c to avoid the Text widget's extra trailing newline
content = src_text.get('1.0', 'end-1c')
dst_text.delete('1.0', 'end')
# if content is empty, ensure destination becomes empty (no extra newline)
if content:
dst_text.insert('1.0', content)
else:
# keep widget empty
pass
self._mark_dirty(dst)
self._refresh_diff()
def _refresh_diff(self) -> None:
"""Recompute the diff from the current in-memory texts and redraw."""
try:
# get current contents
left_content = self.left_text.get('1.0', 'end-1c')
right_content = self.right_text.get('1.0', 'end-1c')
left_lines = left_content.splitlines(keepends=True)
right_lines = right_content.splitlines(keepends=True)
# update stored lines and re-render tags
self._src_lines = left_lines
self._dest_lines = right_lines
self._display_diff()
except Exception as e:
messagebox.showerror('Error', f'Failed to refresh diff: {e}', parent=self)
def _save_changes(self) -> None:
"""Save modified panes back to disk using remembered encodings."""
try:
if self._left_dirty:
data = self.left_text.get('1.0', 'end-1c')
enc = getattr(self, '_left_encoding', 'utf-8') or 'utf-8'
with open(self.source_path, 'w', encoding=enc, errors='replace') as f:
f.write(data)
self._left_dirty = False
if self._right_dirty:
data = self.right_text.get('1.0', 'end-1c')
enc = getattr(self, '_right_encoding', 'utf-8') or 'utf-8'
os.makedirs(os.path.dirname(self.dest_path), exist_ok=True)
with open(self.dest_path, 'w', encoding=enc, errors='replace') as f:
f.write(data)
self._right_dirty = False
messagebox.showinfo('Saved', 'Files saved successfully.', parent=self)
# rebuild minimap
self._build_minimap()
except Exception as e:
messagebox.showerror('Save error', f'Failed to save: {e}', parent=self)
def _on_close(self) -> None:
"""Prompt if there are unsaved changes, then close accordingly."""
if self._left_dirty or self._right_dirty:
resp = messagebox.askyesnocancel('Unsaved changes', 'Save changes before closing?\nYes=Save, No=Discard, Cancel=Keep editing', parent=self)
if resp is True:
self._save_changes()
self.destroy()
elif resp is False:
self.destroy()
else:
return
else:
self.destroy()