This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small desktop front door for Labyricorn repository sync and editors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
from git_repo import GitRepository, GitRepositoryError, RepositoryState
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SITE_ROOT = SCRIPT_DIR.parent
|
||||
EDITOR_SCRIPTS = (
|
||||
("Blog Editor", "blog_editor.py", True),
|
||||
("Article Editor", "article_editor.py", True),
|
||||
("Tag Editor", "tag_editor.py", True),
|
||||
("Project Editor", "project_editor.py", True),
|
||||
)
|
||||
|
||||
|
||||
def run_gui() -> None:
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
class ManagementLauncher:
|
||||
def __init__(self) -> None:
|
||||
self.root = tk.Tk()
|
||||
self.root.title("Labyricorn Management")
|
||||
self.root.minsize(620, 430)
|
||||
self.repository: GitRepository | None = None
|
||||
self.state: RepositoryState | None = None
|
||||
self.busy = False
|
||||
|
||||
outer = ttk.Frame(self.root, padding=14)
|
||||
outer.grid(row=0, column=0, sticky="nsew")
|
||||
self.root.columnconfigure(0, weight=1)
|
||||
self.root.rowconfigure(0, weight=1)
|
||||
outer.columnconfigure(0, weight=1)
|
||||
|
||||
ttk.Label(
|
||||
outer,
|
||||
text="Labyricorn Management",
|
||||
font=("TkDefaultFont", 15, "bold"),
|
||||
).grid(row=0, column=0, sticky="w", pady=(0, 12))
|
||||
|
||||
repository_frame = ttk.LabelFrame(outer, text="Repository", padding=10)
|
||||
repository_frame.grid(row=1, column=0, sticky="ew")
|
||||
repository_frame.columnconfigure(1, weight=1)
|
||||
self.values: dict[str, tk.StringVar] = {}
|
||||
fields = (
|
||||
("Repository root", "root"),
|
||||
("Repository", "repository"),
|
||||
("Branch", "branch"),
|
||||
("Remote", "remote"),
|
||||
("Upstream", "upstream"),
|
||||
("Status", "status"),
|
||||
("Working tree", "working_tree"),
|
||||
)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(repository_frame, text=f"{label}:").grid(
|
||||
row=row, column=0, sticky="nw", padx=(0, 10), pady=2
|
||||
)
|
||||
value = tk.StringVar(value="Checking…" if key == "status" else "—")
|
||||
self.values[key] = value
|
||||
ttk.Label(repository_frame, textvariable=value, wraplength=460).grid(
|
||||
row=row, column=1, sticky="w", pady=2
|
||||
)
|
||||
|
||||
self.details_var = tk.StringVar(value="Fetching remote metadata…")
|
||||
ttk.Label(
|
||||
repository_frame,
|
||||
textvariable=self.details_var,
|
||||
wraplength=560,
|
||||
foreground="#7a5200",
|
||||
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 2))
|
||||
|
||||
controls = ttk.Frame(repository_frame)
|
||||
controls.grid(
|
||||
row=len(fields) + 1,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="w",
|
||||
pady=(10, 0),
|
||||
)
|
||||
self.refresh_button = ttk.Button(controls, text="Refresh", command=self.refresh)
|
||||
self.refresh_button.grid(row=0, column=0)
|
||||
self.pull_button = ttk.Button(
|
||||
controls, text="Pull", command=self.pull, state="disabled"
|
||||
)
|
||||
self.pull_button.grid(row=0, column=1, padx=(8, 0))
|
||||
self.push_button = ttk.Button(
|
||||
controls, text="Push", command=self.push, state="disabled"
|
||||
)
|
||||
self.push_button.grid(row=0, column=2, padx=(8, 0))
|
||||
|
||||
editors_frame = ttk.LabelFrame(outer, text="Editors", padding=10)
|
||||
editors_frame.grid(row=2, column=0, sticky="ew", pady=(14, 0))
|
||||
editors_frame.columnconfigure(0, weight=1)
|
||||
editor_row = 0
|
||||
for label, filename, required in EDITOR_SCRIPTS:
|
||||
path = SCRIPT_DIR / filename
|
||||
if not required and not path.is_file():
|
||||
continue
|
||||
button = ttk.Button(
|
||||
editors_frame,
|
||||
text=label,
|
||||
command=lambda selected=path, name=label: self.launch_editor(
|
||||
selected, name
|
||||
),
|
||||
)
|
||||
button.grid(row=editor_row, column=0, sticky="ew", pady=3)
|
||||
if not path.is_file():
|
||||
button.configure(state="disabled")
|
||||
ttk.Label(editors_frame, text=f"{filename} is missing").grid(
|
||||
row=editor_row, column=1, sticky="w", padx=(10, 0)
|
||||
)
|
||||
editor_row += 1
|
||||
|
||||
self.root.after(50, self.refresh)
|
||||
|
||||
def refresh(self) -> None:
|
||||
def operation() -> RepositoryState:
|
||||
repository = GitRepository.locate(SCRIPT_DIR)
|
||||
state = repository.refresh()
|
||||
self.repository = repository
|
||||
return state
|
||||
|
||||
self._start_git_task("Refreshing repository status", operation)
|
||||
|
||||
def pull(self) -> None:
|
||||
if self.repository is None or self.state is None:
|
||||
return
|
||||
repository = self.repository
|
||||
expected = self.state
|
||||
self._start_git_task(
|
||||
"Pulling with fast-forward only", lambda: repository.pull(expected)
|
||||
)
|
||||
|
||||
def push(self) -> None:
|
||||
if self.repository is None or self.state is None:
|
||||
return
|
||||
repository = self.repository
|
||||
expected = self.state
|
||||
self._start_git_task(
|
||||
"Pushing local commits", lambda: repository.push(expected)
|
||||
)
|
||||
|
||||
def _start_git_task(
|
||||
self, label: str, operation: Callable[[], RepositoryState]
|
||||
) -> None:
|
||||
if self.busy:
|
||||
return
|
||||
self.busy = True
|
||||
self.details_var.set(f"{label}…")
|
||||
self._update_buttons()
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
state = operation()
|
||||
except (GitRepositoryError, OSError) as exc:
|
||||
self.root.after(0, self._finish_error, label, str(exc))
|
||||
else:
|
||||
self.root.after(0, self._finish_success, state)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _finish_success(self, state: RepositoryState) -> None:
|
||||
self.busy = False
|
||||
self.state = state
|
||||
self.values["root"].set(str(state.root))
|
||||
self.values["repository"].set(state.repository_name)
|
||||
self.values["branch"].set(state.branch)
|
||||
if not state.origin_url:
|
||||
remote_text = "Not configured"
|
||||
elif state.origin_push_url and state.origin_push_url != state.origin_url:
|
||||
remote_text = (
|
||||
f"origin — {state.origin_url}\nPush target — {state.origin_push_url}"
|
||||
)
|
||||
else:
|
||||
remote_text = f"origin — {state.origin_url}"
|
||||
self.values["remote"].set(remote_text)
|
||||
self.values["upstream"].set(state.upstream or "Not configured")
|
||||
self.values["status"].set(state.status_text)
|
||||
self.values["working_tree"].set(state.working_tree_text)
|
||||
if state.problem:
|
||||
self.details_var.set(state.problem)
|
||||
elif state.sync_state == "diverged":
|
||||
self.details_var.set(
|
||||
f"Local is {state.ahead} ahead and {state.behind} behind. "
|
||||
"Resolve the histories manually; no automatic action is offered."
|
||||
)
|
||||
elif state.dirty_count and state.sync_state == "remote-ahead":
|
||||
self.details_var.set(
|
||||
"Pull is unavailable until the working-tree changes are "
|
||||
"reviewed or committed."
|
||||
)
|
||||
else:
|
||||
self.details_var.set("Remote metadata fetched successfully.")
|
||||
self._update_buttons()
|
||||
|
||||
def _finish_error(self, label: str, detail: str) -> None:
|
||||
self.busy = False
|
||||
self.state = None
|
||||
self.details_var.set(detail)
|
||||
self.values["status"].set("Unavailable")
|
||||
self._update_buttons()
|
||||
messagebox.showerror(label, detail, parent=self.root)
|
||||
|
||||
def _update_buttons(self) -> None:
|
||||
self.refresh_button.configure(state="disabled" if self.busy else "normal")
|
||||
pull_enabled = (
|
||||
not self.busy and self.state is not None and self.state.can_pull
|
||||
)
|
||||
push_enabled = (
|
||||
not self.busy and self.state is not None and self.state.can_push
|
||||
)
|
||||
self.pull_button.configure(state="normal" if pull_enabled else "disabled")
|
||||
self.push_button.configure(state="normal" if push_enabled else "disabled")
|
||||
|
||||
def launch_editor(self, path: Path, label: str) -> None:
|
||||
if not path.is_file():
|
||||
messagebox.showerror(
|
||||
f"Cannot launch {label}",
|
||||
f"The editor script is missing: {path}",
|
||||
parent=self.root,
|
||||
)
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(path), "--site-root", str(SITE_ROOT)],
|
||||
cwd=SITE_ROOT,
|
||||
)
|
||||
except OSError as exc:
|
||||
messagebox.showerror(
|
||||
f"Cannot launch {label}",
|
||||
f"Could not start {path.name}: {exc}",
|
||||
parent=self.root,
|
||||
)
|
||||
|
||||
try:
|
||||
app = ManagementLauncher()
|
||||
except tk.TclError as exc:
|
||||
raise GitRepositoryError(f"Cannot open the Tkinter window: {exc}") from exc
|
||||
app.root.mainloop()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
run_gui()
|
||||
except GitRepositoryError as exc:
|
||||
print(f"management-launcher: ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user