360 lines
22 KiB
Python
360 lines
22 KiB
Python
# RepoSync/gui/main_window.py
|
|
|
|
"""
|
|
Main window and application logic for the RepoSync GUI.
|
|
Organizes the UI into separate tabs for different functionalities and
|
|
manages server profiles and settings dynamically.
|
|
"""
|
|
|
|
import logging
|
|
import threading
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from tkinter.scrolledtext import ScrolledText
|
|
from pathlib import Path
|
|
|
|
# Custom module imports
|
|
from ..config.profile_manager import ProfileManager
|
|
from .profile_dialog import ProfileDialog
|
|
from ..core.gitea_client import GiteaClient
|
|
from ..core.git_manager import GitManager
|
|
from ..core.sync_manager import SyncManager, SyncState
|
|
from ..utility import logger as logger_util
|
|
|
|
def format_size(size_kb: int) -> str:
|
|
"""Formats size in kilobytes to a human-readable string (KB, MB, GB)."""
|
|
if size_kb < 1024:
|
|
return f"{size_kb} KB"
|
|
elif size_kb < 1024 * 1024:
|
|
return f"{size_kb / 1024:.2f} MB"
|
|
else:
|
|
return f"{size_kb / (1024 * 1024):.2f} GB"
|
|
|
|
def format_bytes(size_bytes: int) -> str:
|
|
"""Formats size in bytes to a human-readable string (B, KB, MB, GB)."""
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes} B"
|
|
return format_size(size_bytes // 1024)
|
|
|
|
|
|
class BaseTab(ttk.Frame):
|
|
"""Base class for our application tabs to reduce code duplication."""
|
|
def __init__(self, parent, app_instance, tab_name, **kwargs):
|
|
super().__init__(parent, **kwargs)
|
|
self.app = app_instance
|
|
self.logger = logging.getLogger(tab_name)
|
|
self.active_client = None
|
|
self.selected_profile_name = tk.StringVar()
|
|
self.sync_bundle_path = None
|
|
|
|
def create_profile_selector(self, parent_frame, label_text):
|
|
"""Creates the profile selection Combobox and status label."""
|
|
selector_frame = ttk.Frame(parent_frame)
|
|
ttk.Label(selector_frame, text=label_text).pack(side="left", padx=(0, 5))
|
|
self.profile_combo = ttk.Combobox(selector_frame, textvariable=self.selected_profile_name, state="readonly", width=25)
|
|
self.profile_combo.pack(side="left")
|
|
self.profile_combo.bind("<<ComboboxSelected>>", self._on_profile_select)
|
|
self.status_label = ttk.Label(selector_frame, text="Status: Not Connected", foreground="red")
|
|
self.status_label.pack(side="left", padx=10)
|
|
return selector_frame
|
|
|
|
def update_profile_list(self):
|
|
"""Updates the list of profiles in the Combobox."""
|
|
profile_names = self.app.profile_manager.get_profile_names()
|
|
self.profile_combo['values'] = sorted(profile_names)
|
|
if not profile_names:
|
|
self.selected_profile_name.set("")
|
|
self._update_status_label(False, "No profiles configured.")
|
|
|
|
def update_bundle_path(self):
|
|
"""Updates the bundle path from the main application's setting."""
|
|
new_path_str = self.app.bundle_path_var.get()
|
|
self.sync_bundle_path = Path(new_path_str) if new_path_str else None
|
|
|
|
def _on_profile_select(self, event=None):
|
|
"""Handles profile selection and initializes the appropriate client."""
|
|
profile_name = self.selected_profile_name.get()
|
|
if not profile_name:
|
|
self.active_client = None
|
|
self._update_status_label(False)
|
|
return
|
|
profile_data = self.app.profile_manager.get_profile(profile_name)
|
|
if not profile_data:
|
|
self.active_client = None
|
|
self.logger.error(f"Could not load data for profile '{profile_name}'.")
|
|
self._update_status_label(False, "Profile data missing.")
|
|
return
|
|
service_type = profile_data.get("type", "gitea")
|
|
if service_type == "gitea":
|
|
try:
|
|
self.active_client = GiteaClient(api_url=profile_data["url"], token=profile_data["token"], logger=logging.getLogger(f"GiteaClient({profile_name})"))
|
|
self._update_status_label(True, f"Selected: {profile_data['url']}")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to initialize GiteaClient for '{profile_name}': {e}")
|
|
self._update_status_label(False, f"Error initializing client.")
|
|
else:
|
|
self.logger.error(f"Unsupported service type '{service_type}' for profile '{profile_name}'.")
|
|
self._update_status_label(False, f"Unsupported type: {service_type}")
|
|
|
|
def _update_status_label(self, is_connected, message=""):
|
|
"""Updates the status label text and color."""
|
|
if is_connected:
|
|
self.status_label.config(text=message or "Status: Connected", foreground="green")
|
|
else:
|
|
self.status_label.config(text=message or "Status: Not Connected", foreground="red")
|
|
|
|
|
|
class ExportTab(BaseTab):
|
|
"""Tab for fetching repos from a server and exporting them to bundles."""
|
|
def __init__(self, parent, app_instance, **kwargs):
|
|
super().__init__(parent, app_instance, "ExportTab", **kwargs)
|
|
self.repositories = []
|
|
self.repo_data_map = {}
|
|
self.selection_count_var = tk.StringVar(value="Selected: 0")
|
|
self.selection_size_var = tk.StringVar(value="Total Est. Size: 0 KB")
|
|
self.total_export_size = 0
|
|
self._create_widgets()
|
|
self.update_profile_list()
|
|
|
|
def _create_widgets(self):
|
|
top_frame = ttk.Frame(self, padding="5")
|
|
top_frame.pack(side="top", fill="x")
|
|
selector_frame = self.create_profile_selector(top_frame, "Export from Server:")
|
|
selector_frame.pack(side="left")
|
|
self.fetch_button = ttk.Button(top_frame, text="Fetch Repositories", command=self._start_repo_fetch)
|
|
self.fetch_button.pack(side="left", padx=10)
|
|
self.fetch_button.state(['disabled'])
|
|
self.export_button = ttk.Button(top_frame, text="Export Selected", command=self._start_export)
|
|
self.export_button.pack(side="left", padx=5)
|
|
self.export_button.state(['disabled'])
|
|
tree_frame = ttk.Frame(self)
|
|
tree_frame.pack(expand=True, fill="both", pady=5)
|
|
columns = ("name", "size", "description", "private")
|
|
self.repo_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="extended")
|
|
self.repo_tree.heading("name", text="Name"); self.repo_tree.heading("size", text="Size (est.)"); self.repo_tree.heading("description", text="Description"); self.repo_tree.heading("private", text="Private")
|
|
self.repo_tree.column("name", width=200, stretch=tk.NO); self.repo_tree.column("size", width=100, stretch=tk.NO, anchor="e"); self.repo_tree.column("description", width=500); self.repo_tree.column("private", width=80, stretch=tk.NO, anchor="center")
|
|
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.repo_tree.yview)
|
|
self.repo_tree.configure(yscrollcommand=vsb.set)
|
|
vsb.pack(side="right", fill="y")
|
|
self.repo_tree.pack(side="left", expand=True, fill="both")
|
|
self.repo_tree.bind("<<TreeviewSelect>>", self._update_selection_info)
|
|
info_frame = ttk.LabelFrame(self, text="Selection Info", padding="5")
|
|
info_frame.pack(side="bottom", fill="x", pady=(5, 0))
|
|
selection_buttons_frame = ttk.Frame(info_frame)
|
|
selection_buttons_frame.pack(side="left", padx=5)
|
|
ttk.Button(selection_buttons_frame, text="Select All", command=self._select_all).pack(side="left")
|
|
ttk.Button(selection_buttons_frame, text="Select None", command=self._deselect_all).pack(side="left", padx=5)
|
|
ttk.Label(info_frame, textvariable=self.selection_count_var).pack(side="left", padx=10)
|
|
ttk.Label(info_frame, textvariable=self.selection_size_var).pack(side="left", padx=10)
|
|
|
|
def _on_profile_select(self, event=None):
|
|
super()._on_profile_select(event)
|
|
self.fetch_button.state(['!disabled'] if self.active_client else ['disabled'])
|
|
self.export_button.state(['disabled'])
|
|
self.repo_tree.delete(*self.repo_tree.get_children())
|
|
self._update_selection_info()
|
|
|
|
def _select_all(self):
|
|
self.repo_tree.selection_set(self.repo_tree.get_children())
|
|
def _deselect_all(self):
|
|
self.repo_tree.selection_set([])
|
|
|
|
def _update_selection_info(self, event=None):
|
|
selected_iids = self.repo_tree.selection()
|
|
count = len(selected_iids)
|
|
total_size_kb = sum(self.repo_data_map[iid].get("size_kb", 0) for iid in selected_iids if iid in self.repo_data_map)
|
|
self.selection_count_var.set(f"Selected: {count}")
|
|
self.selection_size_var.set(f"Total Est. Size: {format_size(total_size_kb)}")
|
|
|
|
def _start_repo_fetch(self):
|
|
if not self.active_client: return
|
|
self.logger.info(f"Fetching repository list from '{self.selected_profile_name.get()}'...")
|
|
self.fetch_button.state(['disabled']); self.export_button.state(['disabled'])
|
|
threading.Thread(target=self._load_repositories_thread, daemon=True).start()
|
|
|
|
def _load_repositories_thread(self):
|
|
try:
|
|
self.repositories = self.active_client.get_repositories()
|
|
self.repo_data_map = {repo['name']: repo for repo in self.repositories}
|
|
self.app.root.after(10, self._update_repo_treeview)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to fetch repositories: {e}", exc_info=True)
|
|
self.app.root.after(10, lambda: messagebox.showerror("Fetch Error", f"Failed to fetch repositories:\n{e}"))
|
|
finally:
|
|
self.app.root.after(10, lambda: self.fetch_button.state(['!disabled']))
|
|
|
|
def _update_repo_treeview(self):
|
|
self.repo_tree.delete(*self.repo_tree.get_children())
|
|
for repo in self.repositories:
|
|
self.repo_tree.insert("", "end", values=(repo["name"], format_size(repo.get("size_kb", 0)), repo.get("description", ""), "Yes" if repo.get("private") else "No"), iid=repo["name"])
|
|
self.logger.info(f"Displayed {len(self.repositories)} repositories.")
|
|
self.export_button.state(['!disabled'] if self.repositories else ['disabled'])
|
|
self._update_selection_info()
|
|
|
|
def _start_export(self):
|
|
selected_iids = self.repo_tree.selection()
|
|
if not selected_iids: return
|
|
if not self.sync_bundle_path: return
|
|
repos_to_export = [repo for repo in self.repositories if repo["name"] in selected_iids]
|
|
|
|
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager"))
|
|
self.logger.info(f"Starting export for {len(repos_to_export)} repositories...")
|
|
self.export_button.state(['disabled']); self.fetch_button.state(['disabled'])
|
|
self.total_export_size = 0
|
|
self.app.status_bar_progress.config(value=0)
|
|
self.app.status_bar_label.config(text="Starting export...")
|
|
|
|
thread = threading.Thread(target=self._export_thread, args=(sync_manager, repos_to_export), daemon=True)
|
|
thread.start()
|
|
|
|
def _progress_callback(self, current, total, bundle_size_bytes):
|
|
progress_percent = (current / total) * 100
|
|
self.total_export_size += bundle_size_bytes
|
|
self.app.status_bar_progress.config(value=progress_percent)
|
|
self.app.status_bar_label.config(text=f"Exporting {current}/{total}...")
|
|
self.app.status_bar_size_label.config(text=f"Total Size: {format_bytes(self.total_export_size)}")
|
|
|
|
def _export_thread(self, sync_manager, repos_to_export):
|
|
try:
|
|
callback = lambda c, t, s: self.app.root.after(0, self._progress_callback, c, t, s)
|
|
sync_manager.export_repositories_to_bundles(repos_to_export, progress_callback=callback)
|
|
self.app.root.after(0, lambda: messagebox.showinfo("Success", "Export completed successfully."))
|
|
except Exception as e:
|
|
self.logger.error(f"Export failed: {e}", exc_info=True)
|
|
self.app.root.after(0, lambda: messagebox.showerror("Export Error", f"An error occurred: {e}"))
|
|
finally:
|
|
self.app.root.after(0, lambda: self.app.status_bar_label.config(text="Export finished."))
|
|
self.app.root.after(0, lambda: self.export_button.state(['!disabled']))
|
|
self.app.root.after(0, lambda: self.fetch_button.state(['!disabled']))
|
|
|
|
|
|
class ImportTab(BaseTab):
|
|
def __init__(self, parent, app_instance, **kwargs):
|
|
super().__init__(parent, app_instance, "ImportTab", **kwargs)
|
|
self.comparison_results = []
|
|
self._create_widgets()
|
|
self.update_profile_list()
|
|
|
|
def _create_widgets(self):
|
|
top_frame = ttk.Frame(self, padding="5"); top_frame.pack(side="top", fill="x")
|
|
selector_frame = self.create_profile_selector(top_frame, "Import to Server:"); selector_frame.pack(side="left")
|
|
self.check_status_button = ttk.Button(top_frame, text="Check Bundle Status", command=self._start_bundle_check); self.check_status_button.pack(side="left", padx=10); self.check_status_button.state(['disabled'])
|
|
self.import_button = ttk.Button(top_frame, text="Import Selected", command=self._start_import); self.import_button.pack(side="left", padx=5); self.import_button.state(['disabled'])
|
|
tree_frame = ttk.Frame(self); tree_frame.pack(expand=True, fill="both", pady=5)
|
|
columns = ("name", "status"); self.bundle_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="extended")
|
|
self.bundle_tree.heading("name", text="Repository Name"); self.bundle_tree.heading("status", text="Sync Status")
|
|
self.bundle_tree.column("name", width=300); self.bundle_tree.column("status", width=200, anchor="center")
|
|
for state in SyncState: self.bundle_tree.tag_configure(state.name, background=self._get_color_for_state(state))
|
|
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.bundle_tree.yview); self.bundle_tree.configure(yscrollcommand=vsb.set); vsb.pack(side="right", fill="y"); self.bundle_tree.pack(side="left", expand=True, fill="both")
|
|
|
|
def _get_color_for_state(self, state): return {SyncState.AHEAD: "lightblue", SyncState.NEW_REPO: "lightgreen", SyncState.BEHIND: "khaki", SyncState.DIVERGED: "salmon", SyncState.ERROR: "lightgrey", SyncState.IDENTICAL: "white"}.get(state, "white")
|
|
def _on_profile_select(self, event=None): super()._on_profile_select(event); self.check_status_button.state(['!disabled'] if self.active_client else ['disabled']); self.import_button.state(['disabled']); self.bundle_tree.delete(*self.bundle_tree.get_children())
|
|
|
|
def _start_bundle_check(self):
|
|
if not self.active_client or not self.sync_bundle_path: return
|
|
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager"))
|
|
self.logger.info("Checking bundle status..."); self.check_status_button.state(['disabled'])
|
|
threading.Thread(target=self._check_status_thread, args=(sync_manager,), daemon=True).start()
|
|
|
|
def _check_status_thread(self, sync_manager):
|
|
try:
|
|
self.comparison_results = sync_manager.compare_bundles_with_remote()
|
|
self.app.root.after(10, self._update_bundle_treeview)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to compare bundle status: {e}", exc_info=True)
|
|
self.app.root.after(10, lambda: messagebox.showerror("Comparison Error", f"Failed to compare bundle status:\n{e}"))
|
|
finally:
|
|
self.app.root.after(10, lambda: self.check_status_button.state(['!disabled']))
|
|
|
|
def _update_bundle_treeview(self):
|
|
self.bundle_tree.delete(*self.bundle_tree.get_children())
|
|
for result in self.comparison_results:
|
|
self.bundle_tree.insert("", "end", values=(result["name"], result["state"].name), tags=(result["state"].name,), iid=result["name"])
|
|
self.import_button.state(['!disabled'] if self.comparison_results else ['disabled'])
|
|
|
|
def _start_import(self):
|
|
selected_iids = self.bundle_tree.selection()
|
|
if not selected_iids: return
|
|
repos_to_import = [r for r in self.comparison_results if r["name"] in selected_iids and r["state"] in [SyncState.AHEAD, SyncState.NEW_REPO]]
|
|
if not repos_to_import:
|
|
messagebox.showwarning("Invalid Selection", "Please select repositories that are 'AHEAD' or 'NEW_REPO' to import.")
|
|
return
|
|
|
|
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager"))
|
|
self.logger.info(f"Starting import for {len(repos_to_import)} repositories...")
|
|
self.import_button.state(['disabled']); self.check_status_button.state(['disabled'])
|
|
|
|
# --- MODIFICATION START ---
|
|
# Using a lambda in the target to ensure the arguments are passed correctly and clearly.
|
|
thread_target = lambda: self._import_thread(sync_manager, repos_to_import)
|
|
threading.Thread(target=thread_target, daemon=True).start()
|
|
# --- MODIFICATION END ---
|
|
|
|
def _import_thread(self, sync_manager, repos_to_import):
|
|
try:
|
|
sync_manager.import_repositories_from_bundles(repos_to_import)
|
|
self.app.root.after(0, lambda: messagebox.showinfo("Success", "Import process completed."))
|
|
except Exception as e:
|
|
self.logger.error(f"Import failed: {e}", exc_info=True)
|
|
# Pass the actual exception to the messagebox for a more informative error.
|
|
self.app.root.after(0, lambda: messagebox.showerror("Import Error", f"An error occurred: {e}"))
|
|
finally:
|
|
self.app.root.after(0, lambda: self.import_button.state(['!disabled']))
|
|
self.app.root.after(0, lambda: self.check_status_button.state(['!disabled']))
|
|
self.app.root.after(0, self._start_bundle_check)
|
|
|
|
|
|
class Application:
|
|
"""The main class for the RepoSync graphical user interface."""
|
|
def __init__(self):
|
|
self.root = tk.Tk(); self.root.title("RepoSync - Offline Repository Synchronization"); self.root.geometry("1200x800")
|
|
self.profile_manager = ProfileManager(); self.git_manager = None; self.bundle_path_var = tk.StringVar()
|
|
self._create_menu(); self._create_main_widgets(); self._setup_logging(); self._init_backend()
|
|
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
|
|
def _create_menu(self):
|
|
menubar = tk.Menu(self.root); self.root.config(menu=menubar)
|
|
file_menu = tk.Menu(menubar, tearoff=0)
|
|
file_menu.add_command(label="Configure Profiles...", command=self._open_profile_dialog)
|
|
file_menu.add_separator(); file_menu.add_command(label="Exit", command=self._on_closing)
|
|
menubar.add_cascade(label="File", menu=file_menu)
|
|
def _create_main_widgets(self):
|
|
top_frame = ttk.Frame(self.root, padding="10"); top_frame.pack(side="top", fill="x", expand=False)
|
|
main_frame = ttk.Frame(self.root, padding=(10, 0, 10, 10)); main_frame.pack(expand=True, fill="both")
|
|
bundle_path_frame = ttk.LabelFrame(top_frame, text="Bundle Folder Path", padding="5"); bundle_path_frame.pack(side="top", fill="x")
|
|
self.bundle_path_entry = ttk.Entry(bundle_path_frame, textvariable=self.bundle_path_var, state="readonly", width=100); self.bundle_path_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
|
browse_button = ttk.Button(bundle_path_frame, text="Browse...", command=self._browse_bundle_path); browse_button.pack(side="left")
|
|
paned_window = ttk.PanedWindow(main_frame, orient=tk.VERTICAL); paned_window.pack(expand=True, fill="both")
|
|
notebook = ttk.Notebook(paned_window); paned_window.add(notebook, weight=3)
|
|
self.export_tab = ExportTab(notebook, self, padding="10"); self.import_tab = ImportTab(notebook, self, padding="10")
|
|
notebook.add(self.export_tab, text="Export from Server"); notebook.add(self.import_tab, text="Import to Server")
|
|
log_frame = ttk.LabelFrame(paned_window, text="Logs", padding="5"); paned_window.add(log_frame, weight=1)
|
|
self.log_widget = ScrolledText(log_frame, state=tk.DISABLED, wrap=tk.WORD, height=10); self.log_widget.pack(expand=True, fill="both")
|
|
status_bar = ttk.Frame(self.root, padding=(5, 2)); status_bar.pack(side="bottom", fill="x")
|
|
self.status_bar_label = ttk.Label(status_bar, text="Ready"); self.status_bar_label.pack(side="left")
|
|
self.status_bar_size_label = ttk.Label(status_bar, text=""); self.status_bar_size_label.pack(side="right")
|
|
self.status_bar_progress = ttk.Progressbar(status_bar, orient='horizontal', mode='determinate'); self.status_bar_progress.pack(side="right", fill="x", expand=True, padx=10)
|
|
def _browse_bundle_path(self):
|
|
initial_dir = self.bundle_path_var.get() or str(Path.home())
|
|
new_path = filedialog.askdirectory(title="Select Bundle Folder", initialdir=initial_dir)
|
|
if new_path:
|
|
self.bundle_path_var.set(new_path); self.profile_manager.set_setting("bundle_path", new_path)
|
|
self.logger.info(f"Bundle path updated to: {new_path}")
|
|
self.export_tab.update_bundle_path(); self.import_tab.update_bundle_path()
|
|
def _setup_logging(self):
|
|
log_config = {"level": logging.INFO, "enable_console": True}; logger_util.setup_logging(self.log_widget, self.root, log_config); self.logger = logging.getLogger(__name__)
|
|
def _init_backend(self):
|
|
self.logger.info("Initializing backend components...")
|
|
self.git_manager = GitManager(logger=logging.getLogger("GitManager"))
|
|
initial_bundle_path = self.profile_manager.get_setting("bundle_path")
|
|
self.bundle_path_var.set(initial_bundle_path)
|
|
self.export_tab.update_bundle_path(); self.import_tab.update_bundle_path()
|
|
self.logger.info("Backend components initialized.")
|
|
def _open_profile_dialog(self):
|
|
dialog = ProfileDialog(self.root, self.profile_manager)
|
|
self.root.wait_window(dialog); self.update_all_profile_comboboxes()
|
|
def update_all_profile_comboboxes(self):
|
|
self.export_tab.update_profile_list(); self.import_tab.update_profile_list()
|
|
def _on_closing(self):
|
|
self.logger.info("Application is closing."); logger_util.shutdown_logging(); self.root.destroy()
|
|
def run(self):
|
|
self.logger.info("Starting RepoSync application..."); self.root.mainloop() |