562 lines
23 KiB
Python
562 lines
23 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 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("<<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):
|
|
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("<<TreeviewSelect>>", 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()
|