355 lines
14 KiB
Python
355 lines
14 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
from tkinter.scrolledtext import ScrolledText
|
|
import os
|
|
import logging
|
|
|
|
# Externals (made importable by __main__.py adding their folders to sys.path)
|
|
try:
|
|
from tkinter_logger import TkinterLogger
|
|
except Exception:
|
|
TkinterLogger = None # type: ignore
|
|
|
|
try:
|
|
from resource_monitor import TkinterResourceMonitor, is_psutil_available
|
|
except Exception:
|
|
TkinterResourceMonitor = None # type: ignore
|
|
is_psutil_available = lambda: False
|
|
from codebridge.core.comparer import CodeComparer
|
|
from codebridge.core.sync_manager import SyncManager
|
|
from codebridge.gui.diff_viewer import DiffViewer
|
|
from codebridge.gui.commit_dialog import CommitDialog
|
|
from codebridge.gui.profile_dialog import ProfileManagerDialog, ProfileManagerFrame
|
|
import json
|
|
|
|
|
|
class MainWindow:
|
|
"""
|
|
Main GUI class for CodeBridge application.
|
|
"""
|
|
|
|
def __init__(self, root: tk.Tk):
|
|
self.root = root
|
|
self.root.title("CodeBridge - Codebase Synchronizer")
|
|
self.root.geometry("900x600")
|
|
self.comparer = None
|
|
self.sync_manager = SyncManager()
|
|
# Initialize optional externals
|
|
self.logger_sys = None
|
|
if TkinterLogger is not None:
|
|
try:
|
|
self.logger_sys = TkinterLogger(self.root)
|
|
# enable console by default; tkinter handler will be added when log window opens
|
|
self.logger_sys.setup(enable_console=True, enable_tkinter=True)
|
|
logging.getLogger(__name__).info("TkinterLogger initialized")
|
|
except Exception:
|
|
self.logger_sys = None
|
|
|
|
# Resource monitor
|
|
self._resource_monitor = None
|
|
self.status_var = tk.StringVar()
|
|
if TkinterResourceMonitor is not None and is_psutil_available():
|
|
try:
|
|
self._resource_monitor = TkinterResourceMonitor(self.root, self.status_var, poll_interval=1.0)
|
|
self._resource_monitor.start()
|
|
except Exception:
|
|
self._resource_monitor = None
|
|
else:
|
|
# psutil not available -> show note
|
|
self.status_var.set("Resource monitor: psutil not installed")
|
|
|
|
# Profiles: load/save path in program root
|
|
try:
|
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
except Exception:
|
|
repo_root = os.getcwd()
|
|
self._profiles_path = os.path.join(repo_root, "profiles.json")
|
|
self.profiles = self._load_profiles()
|
|
self.current_ignore_exts = []
|
|
|
|
# Setup UI and close handler
|
|
self._setup_ui()
|
|
# Attach logger handler to persistent log widget if available
|
|
try:
|
|
if self.logger_sys and hasattr(self, "log_text"):
|
|
try:
|
|
self.logger_sys.add_tkinter_handler(self.log_text)
|
|
except Exception:
|
|
logging.getLogger(__name__).exception("Failed to attach tkinter log handler to main UI")
|
|
except Exception:
|
|
pass
|
|
|
|
# Populate profiles combobox if any
|
|
try:
|
|
self._populate_profiles()
|
|
except Exception:
|
|
pass
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
|
|
|
|
def _setup_ui(self) -> None:
|
|
"""
|
|
Initializes the user interface layout.
|
|
"""
|
|
# Path selection frame
|
|
path_frame = ttk.LabelFrame(self.root, text="Path Selection", padding=10)
|
|
path_frame.pack(fill="x", padx=10, pady=5)
|
|
# Profiles combobox + Manage button
|
|
prof_frame = ttk.Frame(path_frame)
|
|
prof_frame.grid(row=0, column=0, columnspan=3, sticky="ew", pady=(0,6))
|
|
ttk.Label(prof_frame, text="Profile:").pack(side="left")
|
|
self.profile_var = tk.StringVar()
|
|
self.profile_cb = ttk.Combobox(prof_frame, textvariable=self.profile_var, state="readonly", width=40)
|
|
self.profile_cb.pack(side="left", padx=6)
|
|
self.profile_cb.bind("<<ComboboxSelected>>", lambda e: self._on_profile_change())
|
|
ttk.Button(prof_frame, text="⚙️ Manage Profiles...", command=self._open_manage_profiles).pack(side="left")
|
|
self.src_path_var = tk.StringVar()
|
|
self.dst_path_var = tk.StringVar()
|
|
self._create_path_row(path_frame, "Source (New):", self.src_path_var, 0)
|
|
self._create_path_row(path_frame, "Destination (Old):", self.dst_path_var, 1)
|
|
# Action buttons
|
|
btn_frame = ttk.Frame(self.root, padding=5)
|
|
btn_frame.pack(fill="x", padx=10)
|
|
compare_btn = ttk.Button(btn_frame, text="🔍 Compare Folders", command=self._run_comparison)
|
|
compare_btn.pack(side="left", padx=5)
|
|
export_btn = ttk.Button(btn_frame, text="📦 Export Changes (ZIP)", command=self._export_changes)
|
|
export_btn.pack(side="left", padx=5)
|
|
import_btn = ttk.Button(btn_frame, text="📥 Import Package (ZIP)", command=self._import_package)
|
|
import_btn.pack(side="left", padx=5)
|
|
# Results treeview
|
|
self._setup_treeview()
|
|
|
|
def _create_path_row(self, parent: ttk.Frame, label: str, var: tk.StringVar, row: int) -> None:
|
|
"""
|
|
Creates a row with a label, entry and browse button for paths.
|
|
"""
|
|
ttk.Label(parent, text=label).grid(row=row+1, column=0, sticky="w", padx=5)
|
|
entry = ttk.Entry(parent, textvariable=var, width=80)
|
|
entry.grid(row=row+1, column=1, padx=5, pady=2)
|
|
btn = ttk.Button(parent, text="📁 Browse", command=lambda: self._browse_folder(var))
|
|
btn.grid(row=row+1, column=2, padx=5)
|
|
# store references so they can be made readonly when a profile is selected
|
|
if label.lower().startswith("source"):
|
|
self.src_entry = entry
|
|
self.src_browse = btn
|
|
else:
|
|
self.dst_entry = entry
|
|
self.dst_browse = btn
|
|
|
|
def _browse_folder(self, var: tk.StringVar) -> None:
|
|
"""
|
|
Opens a directory selection dialog.
|
|
"""
|
|
directory = filedialog.askdirectory()
|
|
if directory:
|
|
var.set(directory)
|
|
|
|
def _setup_treeview(self) -> None:
|
|
"""
|
|
Configures the treeview to display file differences.
|
|
"""
|
|
tree_frame = ttk.Frame(self.root, padding=10)
|
|
tree_frame.pack(fill="both", expand=True)
|
|
columns = ("status", "path")
|
|
self.tree = ttk.Treeview(tree_frame, columns=columns, show="headings")
|
|
self.tree.heading("status", text="Status")
|
|
self.tree.heading("path", text="File Path")
|
|
self.tree.column("status", width=100, stretch=False)
|
|
self.tree.tag_configure("added", foreground="green")
|
|
self.tree.tag_configure("modified", foreground="orange")
|
|
self.tree.tag_configure("deleted", foreground="red")
|
|
self.tree.bind("<Double-1>", self._on_item_double_click)
|
|
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
|
|
self.tree.configure(yscroll=scrollbar.set)
|
|
self.tree.pack(side="left", fill="both", expand=True)
|
|
scrollbar.pack(side="right", fill="y")
|
|
|
|
# Persistent log box below the treeview
|
|
log_frame = ttk.Frame(self.root, padding=(5, 2))
|
|
log_frame.pack(fill="x")
|
|
self.log_text = ScrolledText(log_frame, height=8, state=tk.DISABLED)
|
|
self.log_text.pack(fill="both", expand=True)
|
|
|
|
# Status bar at bottom
|
|
status_frame = ttk.Frame(self.root, padding=(5, 2))
|
|
status_frame.pack(fill="x", side="bottom")
|
|
ttk.Label(status_frame, textvariable=self.status_var).pack(side="left")
|
|
|
|
# --- Profiles persistence ---
|
|
def _load_profiles(self):
|
|
try:
|
|
if os.path.exists(self._profiles_path):
|
|
with open(self._profiles_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
def _save_profiles(self):
|
|
try:
|
|
with open(self._profiles_path, "w", encoding="utf-8") as f:
|
|
json.dump(self.profiles, f, indent=2)
|
|
except Exception:
|
|
pass
|
|
|
|
def _populate_profiles(self):
|
|
names = ["(None)"] + sorted(self.profiles.keys())
|
|
self.profile_cb["values"] = names
|
|
self.profile_cb.set(names[0])
|
|
|
|
def _open_manage_profiles(self):
|
|
dlg = ProfileManagerDialog(self.root, self._profiles_path, self.profiles)
|
|
self.root.wait_window(dlg)
|
|
# reload and refresh
|
|
self.profiles = self._load_profiles()
|
|
self._populate_profiles()
|
|
|
|
def _close_profile_panel(self):
|
|
try:
|
|
if hasattr(self, "profile_panel") and self.profile_panel and self.profile_panel.winfo_exists():
|
|
self.profile_panel.destroy()
|
|
except Exception:
|
|
pass
|
|
# reload and refresh
|
|
self.profiles = self._load_profiles()
|
|
self._populate_profiles()
|
|
|
|
def _on_profile_change(self):
|
|
sel = self.profile_var.get()
|
|
if not sel or sel == "(None)":
|
|
# enable editing of entries
|
|
try:
|
|
self.src_entry.config(state="normal")
|
|
self.dst_entry.config(state="normal")
|
|
self.src_browse.config(state="normal")
|
|
self.dst_browse.config(state="normal")
|
|
except Exception:
|
|
pass
|
|
self.current_ignore_exts = []
|
|
return
|
|
profile = self.profiles.get(sel)
|
|
if not profile:
|
|
return
|
|
# set entries and make readonly
|
|
self.src_path_var.set(profile.get("source", ""))
|
|
self.dst_path_var.set(profile.get("destination", ""))
|
|
# load ignore extensions for comparisons
|
|
ign = profile.get("ignore_extensions", [])
|
|
if isinstance(ign, (list, tuple)):
|
|
self.current_ignore_exts = ign
|
|
elif isinstance(ign, str) and ign:
|
|
# comma-separated string
|
|
self.current_ignore_exts = [e if e.startswith('.') else '.' + e for e in (x.strip() for x in ign.split(',')) if e]
|
|
else:
|
|
self.current_ignore_exts = []
|
|
try:
|
|
self.src_entry.config(state="readonly")
|
|
self.dst_entry.config(state="readonly")
|
|
self.src_browse.config(state="disabled")
|
|
self.dst_browse.config(state="disabled")
|
|
except Exception:
|
|
pass
|
|
|
|
def _run_comparison(self) -> None:
|
|
"""
|
|
Executes comparison and populates the treeview.
|
|
"""
|
|
src = self.src_path_var.get()
|
|
dst = self.dst_path_var.get()
|
|
if not src or not dst:
|
|
messagebox.showwarning("Warning", "Please select both source and destination folders.")
|
|
return
|
|
self.comparer = CodeComparer(src, dst, ignore_extensions=self.current_ignore_exts)
|
|
added, modified, deleted = self.comparer.compare()
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
for f in added:
|
|
self.tree.insert("", "end", values=("Added", f), tags=("added",))
|
|
for f in modified:
|
|
self.tree.insert("", "end", values=("Modified", f), tags=("modified",))
|
|
for f in deleted:
|
|
self.tree.insert("", "end", values=("Deleted", f), tags=("deleted",))
|
|
|
|
def _on_item_double_click(self, event) -> None:
|
|
"""
|
|
Opens the DiffViewer when a file is double-clicked.
|
|
"""
|
|
selection = self.tree.selection()
|
|
if not selection:
|
|
return
|
|
item_id = selection[0]
|
|
item = self.tree.item(item_id)
|
|
status, relative_path = item["values"]
|
|
if status == "Deleted":
|
|
messagebox.showinfo("Info", "File was deleted. Nothing to compare.")
|
|
return
|
|
source_full_path = os.path.join(self.src_path_var.get(), relative_path)
|
|
dest_full_path = os.path.join(self.dst_path_var.get(), relative_path)
|
|
DiffViewer(self.root, source_full_path, dest_full_path, relative_path)
|
|
|
|
def _export_changes(self) -> None:
|
|
"""
|
|
Prompts for a commit message and exports changes to a ZIP.
|
|
"""
|
|
if not self.comparer:
|
|
messagebox.showwarning("Warning", "Please run comparison first.")
|
|
return
|
|
dialog = CommitDialog(self.root)
|
|
commit_msg = dialog.get_message()
|
|
if not commit_msg:
|
|
messagebox.showwarning("Cancelled", "Export cancelled: Commit message is required.")
|
|
return
|
|
output_zip = filedialog.asksaveasfilename(defaultextension=".zip", filetypes=[("ZIP files", "*.zip")])
|
|
if output_zip:
|
|
changes = {
|
|
"added": self.comparer.added,
|
|
"modified": self.comparer.modified,
|
|
"deleted": self.comparer.deleted
|
|
}
|
|
self.sync_manager.create_export_package(self.src_path_var.get(), changes, output_zip, commit_msg)
|
|
messagebox.showinfo("Success", f"Package created: {output_zip}")
|
|
|
|
def _import_package(self) -> None:
|
|
"""
|
|
Selects a ZIP package and applies it to the destination folder.
|
|
"""
|
|
dst = self.dst_path_var.get()
|
|
if not dst:
|
|
messagebox.showwarning("Warning", "Please select a destination folder first.")
|
|
return
|
|
package_path = filedialog.askopenfilename(filetypes=[("ZIP files", "*.zip")])
|
|
if not package_path:
|
|
return
|
|
confirm = messagebox.askyesno("Confirm Import", "This will overwrite/delete files in the destination. Continue?")
|
|
if confirm:
|
|
commit_msg = self.sync_manager.apply_package(package_path, dst)
|
|
messagebox.showinfo("Import Complete", f"Package applied successfully.\n\nCommit Message:\n{commit_msg}")
|
|
self._run_comparison()
|
|
|
|
|
|
|
|
def _on_closing(self) -> None:
|
|
"""Clean shutdown: stop resource monitor and logging, then exit."""
|
|
try:
|
|
if self._resource_monitor:
|
|
try:
|
|
self._resource_monitor.stop()
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
if self.logger_sys:
|
|
try:
|
|
self.logger_sys.shutdown()
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
self.root.destroy()
|
|
except Exception:
|
|
# fallback
|
|
os._exit(0) |