From cf1db0db602bf6202804d54ef70b0eec5590912a Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Tue, 23 Dec 2025 14:51:48 +0100 Subject: [PATCH] aggiunto venv, aggiunti tasti di copia in fidd, sistemata minimappa, aggiunta la scroolbox orizzontale, rivista disposizione tasti, sistemato anche il tasto diff --- .gitignore | 6 +- README.md | 80 +++++- codebridge/gui/diff_viewer.py | 467 ++++++++++++++++++++++++++++------ codebridge/gui/main_window.py | 12 +- requirements.txt | 12 + scripts/create_venv.bat | 17 ++ scripts/create_venv.sh | 17 ++ 7 files changed, 520 insertions(+), 91 deletions(-) create mode 100644 requirements.txt create mode 100644 scripts/create_venv.bat create mode 100644 scripts/create_venv.sh diff --git a/.gitignore b/.gitignore index 6f08972..114921f 100644 --- a/.gitignore +++ b/.gitignore @@ -150,4 +150,8 @@ dmypy.json *~ _build/ -_dist/ \ No newline at end of file +_dist/ +.venv/ +env/ +venv/ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 3aa5615..0212455 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/codebridge/gui/diff_viewer.py b/codebridge/gui/diff_viewer.py index 41ea274..d3db25e 100644 --- a/codebridge/gui/diff_viewer.py +++ b/codebridge/gui/diff_viewer.py @@ -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,11 +25,23 @@ class DiffViewer(tk.Toplevel): """ 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.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) - copy_all_btn.pack(side="left", padx=5) + 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) @@ -38,31 +51,55 @@ class DiffViewer(tk.Toplevel): main_frame.grid_columnconfigure(1, weight=0) main_frame.grid_columnconfigure(2, weight=1) - left_pane = ttk.Frame(main_frame) - left_pane.grid(row=0, column=0, sticky="nsew") - # Source label with path - src_label = ttk.Label(left_pane, text=f"Source: {self.source_path}", anchor="w") - src_label.pack(side="top", fill="x", padx=2, pady=(2, 4)) - self.left_text = tk.Text(left_pane, wrap="none", undo=True) - self.left_text.pack(side="left", fill="both", expand=True) - self.left_scroll = ttk.Scrollbar(left_pane, orient="vertical", command=lambda *a: self._sync_scroll_from('left', *a)) - self.left_scroll.pack(side="right", fill="y") + # 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_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") 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_pane.grid(row=0, column=2, sticky="nsew") - # Destination label with path - dest_label = ttk.Label(right_pane, text=f"Destination: {self.dest_path}", anchor="w") - dest_label.pack(side="top", fill="x", padx=2, pady=(2, 4)) - self.right_text = tk.Text(right_pane, wrap="none", undo=True) - self.right_text.pack(side="left", fill="both", expand=True) - self.right_scroll = ttk.Scrollbar(right_pane, orient="vertical", command=lambda *a: self._sync_scroll_from('right', *a)) - self.right_scroll.pack(side="right", fill="y") + # 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) @@ -70,11 +107,11 @@ class DiffViewer(tk.Toplevel): # 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 - 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") + # Colors for diff (left: source, right: destination) + self.left_text.tag_config("added", background="#d6ffd6") + self.left_text.tag_config("changed", background="#fff3bf") + self.right_text.tag_config("removed", background="#ffd6d6") + self.right_text.tag_config("changed", background="#fff3bf") # Bind mousewheel to synchronize scrolling between both panes self.left_text.bind("", self._on_mousewheel) self.right_text.bind("", self._on_mousewheel) @@ -88,6 +125,16 @@ class DiffViewer(tk.Toplevel): # rebuild minimap when resized self.minimap.bind("", lambda e: self._build_minimap()) self._minimap_marks = [] + # track encodings and dirty state + self._left_encoding = 'utf-8' + self._right_encoding = 'utf-8' + self._left_dirty = False + self._right_dirty = False + # mark edits + self.left_text.bind('', lambda e: self._mark_dirty('left')) + self.right_text.bind('', lambda e: self._mark_dirty('right')) + # intercept close + self.protocol('WM_DELETE_WINDOW', self._on_close) def _sync_scroll(self, *args) -> None: """ @@ -140,6 +187,64 @@ class DiffViewer(tk.Toplevel): 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: @@ -163,15 +268,19 @@ class DiffViewer(tk.Toplevel): Reads files and highlights differences. """ 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) dest_lines = [] if os.path.exists(self.dest_path): - dest_lines = self._read_file_with_fallback(self.dest_path) - 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 _read_file_with_fallback(self, path: str): @@ -191,56 +300,86 @@ class DiffViewer(tk.Toplevel): for enc in ('utf-8', 'cp1252', 'latin-1'): try: text = data.decode(enc) - return text.splitlines(keepends=True) + 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) + 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.right_text.config(state="normal") - # We'll track line-level statuses to draw the minimap - left_lines = [] - right_lines = [] - 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) - left_lines.append("unchanged") - right_lines.append("unchanged") - elif code == "- ": - self.left_text.insert("end", content, "removed") - self.right_text.insert("end", "\n") - left_lines.append("removed") - elif code == "+ ": - 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 + # 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 - # After building texts, ensure consistent line counts - nleft = int(self.left_text.index('end-1c').split('.')[0]) - nright = int(self.right_text.index('end-1c').split('.')[0]) - # If our tracked arrays are shorter than actual lines, pad with 'unchanged' - while len(left_lines) < nleft: - left_lines.append('unchanged') - while len(right_lines) < nright: - right_lines.append('unchanged') + 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' + 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() - self.left_text.config(state="disabled") - self.right_text.config(state="disabled") def _build_minimap(self) -> None: """Draw a simple minimap showing added/removed/changed lines.""" @@ -249,18 +388,26 @@ class DiffViewer(tk.Toplevel): 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_len = len(getattr(self, '_left_lines_status', [])) - right_len = len(getattr(self, '_right_lines_status', [])) + 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 for removed (from left) and added (from right) - for i, status in enumerate(getattr(self, '_left_lines_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', [])): + # 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: @@ -292,12 +439,174 @@ class DiffViewer(tk.Toplevel): """ 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}") \ No newline at end of file + 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() \ No newline at end of file diff --git a/codebridge/gui/main_window.py b/codebridge/gui/main_window.py index c9273c3..1a23f89 100644 --- a/codebridge/gui/main_window.py +++ b/codebridge/gui/main_window.py @@ -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 @@ -120,8 +121,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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9c25a6f --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/create_venv.bat b/scripts/create_venv.bat new file mode 100644 index 0000000..0f790fa --- /dev/null +++ b/scripts/create_venv.bat @@ -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 \ No newline at end of file diff --git a/scripts/create_venv.sh b/scripts/create_venv.sh new file mode 100644 index 0000000..d44b563 --- /dev/null +++ b/scripts/create_venv.sh @@ -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" \ No newline at end of file