Compare commits

...

5 Commits

10 changed files with 874 additions and 61 deletions

4
.gitignore vendored
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,16 +1,78 @@
# 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
- Feature 1
- Feature 2
**Supported platforms:** Windows, macOS, Linux (requires Python 3.11+ and tkinter)
## 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
...
Please check repository root for license information.

74
codebridge/_version.py Normal file
View File

@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# File generated by PyInstaller GUI Wrapper. DO NOT EDIT MANUALLY.
# Contains build-time information scraped from Git (if available)
# and a helper function to format version strings.
import re
# --- Version Data (Generated) ---
__version__ = "v.0.0.0.6-0-g1208029"
GIT_COMMIT_HASH = "1208029c66239b5e9e5356f9643172941e7cfce9"
GIT_BRANCH = "master"
BUILD_TIMESTAMP = "2025-12-23T13:57:10.904302+00:00"
IS_GIT_REPO = True
# --- Default Values (for comparison or fallback) ---
DEFAULT_VERSION = "0.0.0+unknown"
DEFAULT_COMMIT = "Unknown"
DEFAULT_BRANCH = "Unknown"
# --- Helper Function ---
def get_version_string(format_string=None):
"""
Returns a formatted string based on the build version information.
Args:
format_string (str, optional): A format string using placeholders.
Defaults to "{version} ({branch}/{commit_short})" if None.
Placeholders:
{{version}}: Full version string (e.g., 'v1.0.0-5-gabcdef-dirty')
{{tag}}: Clean tag part if exists (e.g., 'v1.0.0'), else DEFAULT_VERSION.
{{commit}}: Full Git commit hash.
{{commit_short}}: Short Git commit hash (7 chars).
{{branch}}: Git branch name.
{{dirty}}: '-dirty' if the repo was dirty, empty otherwise.
{{timestamp}}: Full build timestamp (ISO 8601 UTC).
{{timestamp_short}}: Build date only (YYYY-MM-DD).
{{is_git}}: 'Git' if IS_GIT_REPO is True, 'Unknown' otherwise.
Returns:
str: The formatted version string, or an error message if formatting fails.
"""
if format_string is None:
format_string = "{version} ({branch}/{commit_short})" # Default format
replacements = {}
try:
replacements['version'] = __version__ if __version__ else DEFAULT_VERSION
replacements['commit'] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT
replacements['commit_short'] = GIT_COMMIT_HASH[:7] if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7 else DEFAULT_COMMIT
replacements['branch'] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH
replacements['timestamp'] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else "Unknown"
replacements['timestamp_short'] = BUILD_TIMESTAMP.split('T')[0] if BUILD_TIMESTAMP and 'T' in BUILD_TIMESTAMP else "Unknown"
replacements['is_git'] = "Git" if IS_GIT_REPO else "Unknown"
replacements['dirty'] = "-dirty" if __version__ and __version__.endswith('-dirty') else ""
tag = DEFAULT_VERSION
if __version__ and IS_GIT_REPO:
match = re.match(r'^(v?([0-9]+(?:\.[0-9]+)*))', __version__)
if match:
tag = match.group(1)
replacements['tag'] = tag
output_string = format_string
for placeholder, value in replacements.items():
pattern = re.compile(r'{{\s*' + re.escape(placeholder) + r'\s*}}')
output_string = pattern.sub(str(value), output_string)
if re.search(r'{\s*\w+\s*}', output_string):
pass # Or log a warning: print(f"Warning: Unreplaced placeholders found: {output_string}")
return output_string
except Exception as e:
return f"[Formatting Error: {e}]"

View File

@ -1,5 +1,6 @@
import tkinter as tk
from tkinter import ttk, messagebox
import tkinter.font as tkfont
import difflib
import os
import shutil
@ -24,30 +25,119 @@ class DiffViewer(tk.Toplevel):
"""
Initializes the side-by-side layout and buttons.
"""
# Toolbar
toolbar = ttk.Frame(self, padding=5)
toolbar.pack(fill="x", side="top")
copy_all_btn = ttk.Button(toolbar, text="⤴ Copy Source to Destination", command=self._copy_source_to_dest)
copy_all_btn.pack(side="left", padx=5)
# Main diff container
# 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)
# Left pane (Source)
self.left_text = tk.Text(main_frame, wrap="none", undo=True)
self.left_text.pack(side="left", fill="both", expand=True)
# Right pane (Destination)
self.right_text = tk.Text(main_frame, wrap="none", undo=True)
self.right_text.pack(side="left", fill="both", expand=True)
# Scrollbar for both
# 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.pack(side="right", fill="y")
self.left_text.config(yscrollcommand=self.v_scroll.set)
self.right_text.config(yscrollcommand=self.v_scroll.set)
# Colors for diff
self.left_text.tag_config("removed", background="#ffeef0")
self.right_text.tag_config("added", background="#e6ffed")
self.left_text.tag_config("changed", background="#fffbdd")
self.right_text.tag_config("changed", background="#fffbdd")
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:
"""
@ -55,58 +145,521 @@ class DiffViewer(tk.Toplevel):
"""
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:
with open(self.source_path, "r", encoding="utf-8") as f:
source_lines = f.readlines()
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):
with open(self.dest_path, "r", encoding="utf-8") as f:
dest_lines = f.readlines()
diff = difflib.ndiff(dest_lines, source_lines)
self._display_diff(diff)
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}")
messagebox.showerror("Error", f"Could not read files: {e}", parent=self)
self.destroy()
def _display_diff(self, diff_generator) -> None:
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.
"""
Parses the diff and populates the text widgets with highlighting.
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")
for line in diff_generator:
code = line[:2]
content = line[2:]
if code == " ":
self.left_text.insert("end", content)
self.right_text.insert("end", content)
elif code == "- ":
self.left_text.insert("end", content, "removed")
self.right_text.insert("end", "\n")
elif code == "+ ":
self.left_text.insert("end", "\n")
self.right_text.insert("end", content, "added")
elif code == "? ":
# 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
self.left_text.config(state="disabled")
self.right_text.config(state="disabled")
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?")
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.")
messagebox.showinfo("Success", "File copied successfully.", parent=self)
self.destroy()
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
# 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()

View File

@ -1,5 +1,6 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import tkinter.font as tkfont
from tkinter.scrolledtext import ScrolledText
import os
import logging
@ -24,6 +25,27 @@ from codebridge.gui.profile_dialog import ProfileManagerDialog, ProfileManagerFr
import json
# --- Import Version Info FOR THE WRAPPER ITSELF ---
try:
# Use absolute import based on package name
from codebridge import _version as wrapper_version
WRAPPER_APP_VERSION_STRING = f"{wrapper_version.__version__} ({wrapper_version.GIT_BRANCH}/{wrapper_version.GIT_COMMIT_HASH[:7]})"
WRAPPER_BUILD_INFO = f"Wrapper Built: {wrapper_version.BUILD_TIMESTAMP}"
except ImportError:
# This might happen if you run the wrapper directly from source
# without generating its _version.py first (if you use that approach for the wrapper itself)
WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)"
WRAPPER_BUILD_INFO = "Wrapper build time unknown"
# --- End Import Version Info ---
# --- Constants for Version Generation ---
DEFAULT_VERSION = "0.0.0+unknown"
DEFAULT_COMMIT = "Unknown"
DEFAULT_BRANCH = "Unknown"
# --- End Constants --
class MainWindow:
"""
Main GUI class for CodeBridge application.
@ -31,7 +53,7 @@ class MainWindow:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("CodeBridge - Codebase Synchronizer")
self.root.title(f"CodeBridge - Codebase Synchronizer - {WRAPPER_APP_VERSION_STRING}")
# Make the window taller by default so status/log area is visible
self.root.geometry("1000x760")
# Enforce a reasonable minimum size so status bar remains visible
@ -120,8 +142,15 @@ class MainWindow:
actions_row.pack(fill="x", padx=10)
actions_frame = ttk.LabelFrame(actions_row, text="Actions", padding=6)
actions_frame.pack(side="left", fill="x", expand=True)
self.compare_btn = ttk.Button(actions_frame, text="🔍 Compare Folders", command=self._run_comparison)
self.compare_btn.pack(side="left", padx=5)
# create a prominent style for the main Compare button
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.pack(side="left", padx=5)
self.import_btn = ttk.Button(actions_frame, text="📥 Import Package (ZIP)", command=self._import_package)

View File

@ -43,5 +43,50 @@
".mk",
".bak"
]
},
"DevEnv da server svn": {
"description": "DevEnv Grifo E",
"source": "//tsclient/D/__BACKUP/GrifoE/GRIFO-E_svn/DevEnv",
"destination": "C:/src/GRIFO-E - Copia/DevEnv",
"ignore_extensions": [
".o",
".d",
".obj",
".class",
".pyc",
".pyo",
".log",
".tmp",
".swp",
".DS_Store",
".exe",
".a",
".mk",
".bak",
".defs",
".txt",
".pdom"
]
},
"REP da server svn": {
"description": "REP Grifo E code base",
"source": "//tsclient/D/__BACKUP/GrifoE/GRIFO-E_svn/REP",
"destination": "C:/src/GRIFO-E - Copia/REP",
"ignore_extensions": [
".o",
".d",
".obj",
".class",
".pyc",
".pyo",
".log",
".tmp",
".swp",
".DS_Store",
".exe",
".a",
".mk",
".bak"
]
}
}

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"