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("", self._on_mousewheel) self.right_text.bind("", self._on_mousewheel) # For X11 systems self.left_text.bind("", self._on_mousewheel) self.left_text.bind("", self._on_mousewheel) self.right_text.bind("", self._on_mousewheel) self.right_text.bind("", self._on_mousewheel) # Minimap interactions self.minimap.bind("", self._on_minimap_click) # rebuild minimap when resized self.minimap.bind("", 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('', lambda e: self._mark_dirty('left')) self.right_text.bind('', 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()