# 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("<>", 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("<>", 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()