Compare commits

...

4 Commits

10 changed files with 714 additions and 92 deletions

4
.gitignore vendored
View File

@ -151,3 +151,7 @@ dmypy.json
_build/ _build/
_dist/ _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 # 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.

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 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,14 @@ 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") # inline changed region (more accentuated)
self.right_text.tag_config("changed", background="#fffbdd") 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 # 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 +128,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 +190,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 +271,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 +303,130 @@ 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'
# 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 # 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 +435,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 +486,180 @@ 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
# 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 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
@ -24,6 +25,27 @@ from codebridge.gui.profile_dialog import ProfileManagerDialog, ProfileManagerFr
import json 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: class MainWindow:
""" """
Main GUI class for CodeBridge application. Main GUI class for CodeBridge application.
@ -31,7 +53,7 @@ class MainWindow:
def __init__(self, root: tk.Tk): def __init__(self, root: tk.Tk):
self.root = root 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 # Make the window taller by default so status/log area is visible
self.root.geometry("1000x760") self.root.geometry("1000x760")
# Enforce a reasonable minimum size so status bar remains visible # Enforce a reasonable minimum size so status bar remains visible
@ -120,8 +142,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)

View File

@ -43,5 +43,50 @@
".mk", ".mk",
".bak" ".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"