aggiunto venv, aggiunti tasti di copia in fidd, sistemata minimappa, aggiunta la scroolbox orizzontale, rivista disposizione tasti, sistemato anche il tasto diff

This commit is contained in:
VALLONGOL 2025-12-23 14:51:48 +01:00
parent d5d1ed9336
commit a32bf118f2
7 changed files with 520 additions and 91 deletions

4
.gitignore vendored
View File

@ -151,3 +151,7 @@ dmypy.json
_build/ _build/
_dist/ _dist/
.venv/
env/
venv/
.DS_Store

View File

@ -1,16 +1,78 @@
# SXXXXXXX_CodeBridge # SXXXXXXX_CodeBridge
A brief description of SXXXXXXX_CodeBridge. SXXXXXXX_CodeBridge is a small desktop utility that compares two directory trees, creates ZIP "export packages" containing changed files and a JSON manifest, and can apply those packages to target trees. It is intended for lightweight synchronization and code/package exchange workflows.
## Features **Supported platforms:** Windows, macOS, Linux (requires Python 3.11+ and tkinter)
- Feature 1
- Feature 2
## Getting Started ## Features / Caratteristiche
... - Compare two directory trees and show added, modified and deleted files
- Produce a ZIP export package with a `bridge_manifest.json` describing the changes
- Apply an export package to a destination tree (handles deletions and file extraction)
- GUI with side-by-side diff viewer, minimap, profile-based ignore lists, and embedded logs
## Contributing ## Quick Start / Avvio rapido
... English
1. Install Python 3.11+ and ensure `tkinter` is available.
2. From the repository root run:
```bash
python -m codebridge
```
### Running in a local virtual environment / Eseguire in un ambiente .venv
It is recommended to run the application inside an isolated Python virtual environment.
Windows (PowerShell or Command Prompt):
```powershell
# from repository root
scripts\create_venv.bat
# then activate
.venv\Scripts\activate.bat
python -m codebridge
```
Unix / macOS (bash / zsh):
```bash
# from repository root
./scripts/create_venv.sh
source .venv/bin/activate
python -m codebridge
```
Notes / Note:
- `tkinter` is part of the Python standard library but may need the platform GUI packages installed (e.g., `sudo apt install python3-tk` on Debian/Ubuntu).
- Add any external dependencies to `requirements.txt` and re-run the script to install them into `.venv`.
Italiano
1. Installa Python 3.11+ e assicurati che `tkinter` sia disponibile.
2. Dalla cartella del repository esegui:
```bash
python -m codebridge
```
## Data contract / Manifest
Exported ZIP packages contain a `bridge_manifest.json` with the following keys:
```json
{
"timestamp": "2023-10-27T10:00:00",
"commit_message": "...",
"changes": {"added": [], "modified": [], "deleted": []}
}
```
This manifest is used when applying a package so deletions are handled and modified/added files are extracted.
## Profiles and ignore lists / Profili e liste di ignore
The application supports named profiles that persist to `profiles.json` at the repository root. Each profile can include `ignore_extensions` so you can exclude build artifacts or temporary files from comparisons.
## Contributing / Contribuire
- Please follow PEP8 style for Python changes.
- GUI and core logic are separated under `codebridge/gui` and `codebridge/core` — keep UI-only changes in `gui` and logic changes in `core`.
## License ## License
... Please check repository root for license information.

View File

@ -1,5 +1,6 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox
import tkinter.font as tkfont
import difflib import difflib
import os import os
import shutil import shutil
@ -24,11 +25,23 @@ class DiffViewer(tk.Toplevel):
""" """
Initializes the side-by-side layout and buttons. Initializes the side-by-side layout and buttons.
""" """
# Toolbar (LabelFrame) for future buttons # Top toolbar: navigation / refresh / save
toolbar = ttk.LabelFrame(self, text="Actions", padding=6) toolbar = ttk.LabelFrame(self, text="Actions", padding=6)
toolbar.pack(fill="x", side="top", padx=5, pady=3) toolbar.pack(fill="x", side="top", padx=5, pady=3)
copy_all_btn = ttk.Button(toolbar, text="⤴ Copy Source to Destination", command=self._copy_source_to_dest) ttk.Button(toolbar, text="➡ Next Diff", command=self._next_diff).pack(side="left", padx=4)
copy_all_btn.pack(side="left", padx=5) 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 diff container: left text, minimap, right text, then scrollbar
main_frame = ttk.Frame(self) main_frame = ttk.Frame(self)
main_frame.pack(fill="both", expand=True, padx=5, pady=5) main_frame.pack(fill="both", expand=True, padx=5, pady=5)
@ -38,31 +51,55 @@ class DiffViewer(tk.Toplevel):
main_frame.grid_columnconfigure(1, weight=0) main_frame.grid_columnconfigure(1, weight=0)
main_frame.grid_columnconfigure(2, weight=1) main_frame.grid_columnconfigure(2, weight=1)
left_pane = ttk.Frame(main_frame) # Left: Source frame with path and copy-buttons
left_pane.grid(row=0, column=0, sticky="nsew") left_frame = ttk.LabelFrame(main_frame, text="Source", padding=4)
# Source label with path left_frame.grid(row=0, column=0, sticky="nsew")
src_label = ttk.Label(left_pane, text=f"Source: {self.source_path}", anchor="w") # use grid inside left_frame so horizontal scrollbar spans text width
src_label.pack(side="top", fill="x", padx=2, pady=(2, 4)) left_frame.grid_rowconfigure(2, weight=1)
self.left_text = tk.Text(left_pane, wrap="none", undo=True) left_frame.grid_columnconfigure(0, weight=1)
self.left_text.pack(side="left", fill="both", expand=True) src_label = ttk.Label(left_frame, text=f"{self.source_path}", anchor="w")
self.left_scroll = ttk.Scrollbar(left_pane, orient="vertical", command=lambda *a: self._sync_scroll_from('left', *a)) src_label.grid(row=0, column=0, columnspan=2, sticky="ew", padx=2, pady=(2, 4))
self.left_scroll.pack(side="right", fill="y") 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 # Minimap between the two text widgets
center_pane = ttk.Frame(main_frame, width=60) # Center: Map frame
center_pane = ttk.LabelFrame(main_frame, text="Map", padding=4)
center_pane.grid(row=0, column=1, sticky="ns") 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 = tk.Canvas(center_pane, width=60, bg="#f0f0f0", highlightthickness=1, highlightbackground='black')
self.minimap.pack(fill="y", expand=True, padx=4, pady=2) self.minimap.pack(fill="both", expand=True, padx=4, pady=2)
right_pane = ttk.Frame(main_frame) # Right: Destination frame with path and copy-buttons
right_pane.grid(row=0, column=2, sticky="nsew") right_frame = ttk.LabelFrame(main_frame, text="Destination", padding=4)
# Destination label with path right_frame.grid(row=0, column=2, sticky="nsew")
dest_label = ttk.Label(right_pane, text=f"Destination: {self.dest_path}", anchor="w") # use grid inside right_frame so horizontal scrollbar spans text width
dest_label.pack(side="top", fill="x", padx=2, pady=(2, 4)) right_frame.grid_rowconfigure(2, weight=1)
self.right_text = tk.Text(right_pane, wrap="none", undo=True) right_frame.grid_columnconfigure(0, weight=1)
self.right_text.pack(side="left", fill="both", expand=True) dest_label = ttk.Label(right_frame, text=f"{self.dest_path}", anchor="w")
self.right_scroll = ttk.Scrollbar(right_pane, orient="vertical", command=lambda *a: self._sync_scroll_from('right', *a)) dest_label.grid(row=0, column=0, columnspan=2, sticky="ew", padx=2, pady=(2, 4))
self.right_scroll.pack(side="right", fill="y") 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) # Scrollbar for both text widgets (rightmost)
self.v_scroll = ttk.Scrollbar(main_frame, orient="vertical", command=self._sync_scroll) self.v_scroll = ttk.Scrollbar(main_frame, orient="vertical", command=self._sync_scroll)
@ -70,11 +107,11 @@ class DiffViewer(tk.Toplevel):
# Use custom yscrollcommand so we can update minimap viewport # Use custom yscrollcommand so we can update minimap viewport
self.left_text.config(yscrollcommand=lambda *a: self._on_yscroll('left', *a)) self.left_text.config(yscrollcommand=lambda *a: self._on_yscroll('left', *a))
self.right_text.config(yscrollcommand=lambda *a: self._on_yscroll('right', *a)) self.right_text.config(yscrollcommand=lambda *a: self._on_yscroll('right', *a))
# Colors for diff # Colors for diff (left: source, right: destination)
self.left_text.tag_config("removed", background="#ffeef0") self.left_text.tag_config("added", background="#d6ffd6")
self.right_text.tag_config("added", background="#e6ffed") self.left_text.tag_config("changed", background="#fff3bf")
self.left_text.tag_config("changed", background="#fffbdd") self.right_text.tag_config("removed", background="#ffd6d6")
self.right_text.tag_config("changed", background="#fffbdd") self.right_text.tag_config("changed", background="#fff3bf")
# Bind mousewheel to synchronize scrolling between both panes # Bind mousewheel to synchronize scrolling between both panes
self.left_text.bind("<MouseWheel>", self._on_mousewheel) self.left_text.bind("<MouseWheel>", self._on_mousewheel)
self.right_text.bind("<MouseWheel>", self._on_mousewheel) self.right_text.bind("<MouseWheel>", self._on_mousewheel)
@ -88,6 +125,16 @@ class DiffViewer(tk.Toplevel):
# rebuild minimap when resized # rebuild minimap when resized
self.minimap.bind("<Configure>", lambda e: self._build_minimap()) self.minimap.bind("<Configure>", lambda e: self._build_minimap())
self._minimap_marks = [] 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: def _sync_scroll(self, *args) -> None:
""" """
@ -140,6 +187,64 @@ class DiffViewer(tk.Toplevel):
except Exception: except Exception:
pass 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: def _on_mousewheel(self, event) -> str:
"""Handle mouse wheel events to scroll both panes together.""" """Handle mouse wheel events to scroll both panes together."""
try: try:
@ -163,15 +268,19 @@ class DiffViewer(tk.Toplevel):
Reads files and highlights differences. Reads files and highlights differences.
""" """
try: try:
source_lines = self._read_file_with_fallback(self.source_path) 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) # Handle case where destination file might not exist (Added files)
dest_lines = [] dest_lines = []
if os.path.exists(self.dest_path): if os.path.exists(self.dest_path):
dest_lines = self._read_file_with_fallback(self.dest_path) dest_lines, enc2 = self._read_file_with_fallback(self.dest_path)
diff = difflib.ndiff(dest_lines, source_lines) self._right_encoding = enc2 or 'utf-8'
self._display_diff(diff) # store lines for display rendering
self._src_lines = source_lines
self._dest_lines = dest_lines
self._display_diff()
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Could not read files: {e}") messagebox.showerror("Error", f"Could not read files: {e}", parent=self)
self.destroy() self.destroy()
def _read_file_with_fallback(self, path: str): def _read_file_with_fallback(self, path: str):
@ -191,56 +300,86 @@ class DiffViewer(tk.Toplevel):
for enc in ('utf-8', 'cp1252', 'latin-1'): for enc in ('utf-8', 'cp1252', 'latin-1'):
try: try:
text = data.decode(enc) text = data.decode(enc)
return text.splitlines(keepends=True) return text.splitlines(keepends=True), enc
except UnicodeDecodeError: except UnicodeDecodeError:
continue continue
# As a last resort, decode with replacement to avoid crashing # As a last resort, decode with replacement to avoid crashing
text = data.decode('utf-8', errors='replace') text = data.decode('utf-8', errors='replace')
return text.splitlines(keepends=True) return text.splitlines(keepends=True), 'utf-8'
def _display_diff(self, diff_generator) -> None: def _display_diff(self) -> None:
""" """
Parses the diff and populates the text widgets with highlighting. 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.left_text.config(state="normal")
self.right_text.config(state="normal") self.right_text.config(state="normal")
# We'll track line-level statuses to draw the minimap # clear widgets
left_lines = [] self.left_text.delete('1.0', 'end')
right_lines = [] self.right_text.delete('1.0', 'end')
for line in diff_generator: # source (left) and dest (right) lines
code = line[:2] src_lines = getattr(self, '_src_lines', [])
content = line[2:] dest_lines = getattr(self, '_dest_lines', [])
if code == " ": # insert full contents as-is
self.left_text.insert("end", content) self.left_text.insert('1.0', ''.join(src_lines))
self.right_text.insert("end", content) self.right_text.insert('1.0', ''.join(dest_lines))
left_lines.append("unchanged") # prepare status arrays for minimap
right_lines.append("unchanged") left_lines = ['unchanged'] * len(src_lines)
elif code == "- ": right_lines = ['unchanged'] * len(dest_lines)
self.left_text.insert("end", content, "removed") # compute opcodes with dest as a, source as b
self.right_text.insert("end", "\n") matcher = difflib.SequenceMatcher(None, dest_lines, src_lines)
left_lines.append("removed") for tag, i1, i2, j1, j2 in matcher.get_opcodes():
elif code == "+ ": if tag == 'equal':
self.left_text.insert("end", "\n")
self.right_text.insert("end", content, "added")
right_lines.append("added")
elif code == "? ":
# fine-grained markers ignored for now
continue continue
# After building texts, ensure consistent line counts if tag == 'replace':
nleft = int(self.left_text.index('end-1c').split('.')[0]) # mark changed ranges on both sides
nright = int(self.right_text.index('end-1c').split('.')[0]) if j2 > j1:
# If our tracked arrays are shorter than actual lines, pad with 'unchanged' start = f"{j1+1}.0"
while len(left_lines) < nleft: end = f"{j2+1}.0"
left_lines.append('unchanged') self.left_text.tag_add('changed', start, end)
while len(right_lines) < nright: for k in range(j1, j2):
right_lines.append('unchanged') 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'
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 # store for minimap rendering
self._left_lines_status = left_lines self._left_lines_status = left_lines
self._right_lines_status = right_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 # build minimap markers
self._build_minimap() self._build_minimap()
self.left_text.config(state="disabled")
self.right_text.config(state="disabled")
def _build_minimap(self) -> None: def _build_minimap(self) -> None:
"""Draw a simple minimap showing added/removed/changed lines.""" """Draw a simple minimap showing added/removed/changed lines."""
@ -249,18 +388,26 @@ class DiffViewer(tk.Toplevel):
height = max(100, self.minimap.winfo_height()) height = max(100, self.minimap.winfo_height())
width = max(30, self.minimap.winfo_width()) width = max(30, self.minimap.winfo_width())
# choose a unified total based on the maximum number of lines # choose a unified total based on the maximum number of lines
left_len = len(getattr(self, '_left_lines_status', [])) left_status = getattr(self, '_left_lines_status', [])
right_len = len(getattr(self, '_right_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) total = max(left_len, right_len, 1)
# draw markers for removed (from left) and added (from right) # draw markers: additions on left (green), deletions on right (red), changes (yellow)
for i, status in enumerate(getattr(self, '_left_lines_status', [])): for i, status in enumerate(left_status):
if status == 'removed':
y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ff4d4d', outline='')
for i, status in enumerate(getattr(self, '_right_lines_status', [])):
if status == 'added': if status == 'added':
y = int((i / total) * height) y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#4dff4d', outline='') 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 # viewport rectangle
self._update_minimap_viewport() self._update_minimap_viewport()
except Exception: except Exception:
@ -292,12 +439,174 @@ class DiffViewer(tk.Toplevel):
""" """
Overwrites the destination file with the source file content. Overwrites the destination file with the source file content.
""" """
confirm = messagebox.askyesno("Confirm", "Overwrite destination file with source content?") confirm = messagebox.askyesno("Confirm", "Overwrite destination file with source content?", parent=self)
if confirm: if confirm:
try: try:
os.makedirs(os.path.dirname(self.dest_path), exist_ok=True) os.makedirs(os.path.dirname(self.dest_path), exist_ok=True)
shutil.copy2(self.source_path, self.dest_path) shutil.copy2(self.source_path, self.dest_path)
messagebox.showinfo("Success", "File copied successfully.") messagebox.showinfo("Success", "File copied successfully.", parent=self)
self.destroy() self.destroy()
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Failed to copy file: {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
content = src_text.get('1.0', 'end')
dst_text.delete('1.0', 'end')
dst_text.insert('1.0', content)
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()

View File

@ -1,5 +1,6 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk, filedialog, messagebox from tkinter import ttk, filedialog, messagebox
import tkinter.font as tkfont
from tkinter.scrolledtext import ScrolledText from tkinter.scrolledtext import ScrolledText
import os import os
import logging import logging
@ -120,8 +121,15 @@ class MainWindow:
actions_row.pack(fill="x", padx=10) actions_row.pack(fill="x", padx=10)
actions_frame = ttk.LabelFrame(actions_row, text="Actions", padding=6) actions_frame = ttk.LabelFrame(actions_row, text="Actions", padding=6)
actions_frame.pack(side="left", fill="x", expand=True) actions_frame.pack(side="left", fill="x", expand=True)
self.compare_btn = ttk.Button(actions_frame, text="🔍 Compare Folders", command=self._run_comparison) # create a prominent style for the main Compare button
self.compare_btn.pack(side="left", padx=5) style = ttk.Style(self.root)
try:
big_font = tkfont.Font(self.root, family="TkDefaultFont", size=11, weight="bold")
style.configure('Big.TButton', font=big_font, padding=(10, 6))
except Exception:
style.configure('Big.TButton', padding=(8, 4))
self.compare_btn = ttk.Button(actions_frame, text="🔍 Compare Folders", command=self._run_comparison, style='Big.TButton')
self.compare_btn.pack(side="left", padx=8)
self.export_btn = ttk.Button(actions_frame, text="📦 Export Changes (ZIP)", command=self._export_changes) self.export_btn = ttk.Button(actions_frame, text="📦 Export Changes (ZIP)", command=self._export_changes)
self.export_btn.pack(side="left", padx=5) self.export_btn.pack(side="left", padx=5)
self.import_btn = ttk.Button(actions_frame, text="📥 Import Package (ZIP)", command=self._import_package) self.import_btn = ttk.Button(actions_frame, text="📥 Import Package (ZIP)", command=self._import_package)

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
# Project Python dependencies
# The core application primarily uses the Python standard library (tkinter, zipfile, hashlib, json, shutil).
# These optional runtime dependencies are recommended for full functionality of the bundled externals.
# Optional: resource monitoring support
psutil>=5.9.0
# Optional: robust charset detection/normalization if you add encoding heuristics
charset-normalizer>=3.0.0
# Dev / test dependencies (not required at runtime)
# pytest>=7.0.0

17
scripts/create_venv.bat Normal file
View File

@ -0,0 +1,17 @@
@echo off
REM Create a local virtual environment in .venv and install dependencies
IF EXIST .venv (
echo .venv already exists. Skipping venv creation.
) ELSE (
python -m venv .venv
)
echo Activating and installing requirements...
call .venv\Scripts\activate.bat
pip install --upgrade pip
if exist requirements.txt (
pip install -r requirements.txt
) else (
echo No requirements.txt found - nothing to install.
)
echo Virtual environment ready. Activate it with:
echo .venv\Scripts\activate.bat

17
scripts/create_venv.sh Normal file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Create a local virtual environment in .venv and install dependencies
set -e
if [ -d .venv ]; then
echo ".venv already exists. Skipping venv creation."
else
python3 -m venv .venv
fi
echo "Activating and installing requirements..."
source .venv/bin/activate
pip install --upgrade pip
if [ -f requirements.txt ]; then
pip install -r requirements.txt
else
echo "No requirements.txt found - nothing to install."
fi
echo "Virtual environment ready. Activate it with: source .venv/bin/activate"