# 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 not isinstance(size_kb, (int, float)) or size_kb < 0: return "0 KB" if size_kb < 1024: return f"{size_kb} KB" if size_kb < 1024 * 1024: return f"{size_kb / 1024:.2f} MB" 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 not isinstance(size_bytes, (int, float)) or size_bytes < 0: return "0 B" 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): 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): 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): 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): 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._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._update_status_label(False, "Error initializing client.") else: self._update_status_label(False, f"Unsupported type: {service_type}") def _update_status_label(self, is_connected, message=""): 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']) 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 & Actions", 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) labels_frame = ttk.Frame(info_frame); labels_frame.pack(side="left", padx=10) ttk.Label(labels_frame, textvariable=self.selection_count_var).pack(anchor="w") ttk.Label(labels_frame, textvariable=self.selection_size_var).pack(anchor="w") self.export_button = ttk.Button(info_frame, text="Export Selected", command=self._start_export); self.export_button.pack(side="right", padx=10, pady=5); self.export_button.state(['disabled']) 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()); self._update_selection_info() def _deselect_all(self): self.repo_tree.selection_set([]); self._update_selection_info() 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.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: 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.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 or 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.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...") git_timeout = self.app.profile_manager.get_setting("git_timeout", 300) threading.Thread(target=self._export_thread, args=(sync_manager, repos_to_export, git_timeout), daemon=True).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, git_timeout): 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, git_timeout, progress_callback=callback) self.app.root.after(0, lambda: messagebox.showinfo("Success", "Export completed successfully.")) except Exception as e: 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.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: 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 'AHEAD' or 'NEW_REPO' repos."); return sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager")) self.import_button.state(['disabled']); self.check_status_button.state(['disabled']) git_timeout = self.app.profile_manager.get_setting("git_timeout", 300) threading.Thread(target=self._import_thread, args=(sync_manager, repos_to_import, git_timeout), daemon=True).start() def _import_thread(self, sync_manager, repos_to_import, git_timeout): try: sync_manager.import_repositories_from_bundles(repos_to_import, git_timeout) messagebox.showinfo("Success", "Import process completed.") except Exception as e: messagebox.showerror("Import Error", f"An error occurred: {e}") finally: self.app.root.after(10, lambda: self.import_button.state(['!disabled'])) self.app.root.after(10, lambda: self.check_status_button.state(['!disabled'])) self.app.root.after(10, 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.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()