from __future__ import annotations
import json
import subprocess
import threading
from pathlib import Path
import gi
gi.require_version("Adw", "1")
gi.require_version("Gtk", "4.0")
from gi.repository import Adw, GLib, GObject, Gtk
from ..converter import convert_to_deb822
from ..models import FileFormat, Repository
from ..paths import PKEXEC, POLKIT_HELPER
from ..writer import repo_to_deb822
[docs]
class DetailPane(Gtk.Box):
"""Right-hand editing panel for a selected Repository."""
__gtype_name__ = "RepomanDetailPane"
repo_saved = GObject.Signal("repo-saved", arg_types=(object,))
repo_removed = GObject.Signal("repo-removed", arg_types=(object,))
def __init__(self, **kwargs) -> None:
super().__init__(
orientation=Gtk.Orientation.VERTICAL,
spacing=0,
**kwargs,
)
self._repo: Repository | None = None
self._build_ui()
self._show_empty_state()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
[docs]
def show_repo(self, repo: Repository) -> None:
self._repo = repo
self._populate(repo)
self._stack.set_visible_child_name("detail")
[docs]
def clear(self) -> None:
self._repo = None
self._show_empty_state()
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _build_ui(self) -> None:
self._stack = Gtk.Stack()
# Empty state
empty = Adw.StatusPage(
title="No repository selected",
description="Select a repository from the list to view its details.",
icon_name="folder-symbolic",
vexpand=True,
)
self._stack.add_named(empty, "empty")
# Detail view
scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
detail_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=16,
margin_top=24,
margin_bottom=24,
margin_start=24,
margin_end=24,
)
scroll.set_child(detail_box)
self._stack.add_named(scroll, "detail")
# Info group (read-only)
info_group = Adw.PreferencesGroup(title="Repository")
detail_box.append(info_group)
self._uri_row = Adw.ActionRow(title="URI")
self._uri_row.set_subtitle_selectable(True)
copy_btn = Gtk.Button.new_from_icon_name("edit-copy-symbolic")
copy_btn.set_tooltip_text("Copy URL to clipboard")
copy_btn.add_css_class("flat")
copy_btn.set_valign(Gtk.Align.CENTER)
copy_btn.connect("clicked", self._on_copy_uri)
self._uri_row.add_suffix(copy_btn)
self._open_uri_btn = Gtk.Button.new_from_icon_name("applications-internet-symbolic")
self._open_uri_btn.set_tooltip_text("Open in browser")
self._open_uri_btn.add_css_class("flat")
self._open_uri_btn.set_valign(Gtk.Align.CENTER)
self._open_uri_btn.connect("clicked", self._on_open_uri)
self._uri_row.add_suffix(self._open_uri_btn)
info_group.add(self._uri_row)
self._format_row = Adw.ActionRow(title="Format")
info_group.add(self._format_row)
self._file_row = Adw.ActionRow(title="File")
info_group.add(self._file_row)
# Edit group
edit_group = Adw.PreferencesGroup(title="Settings")
detail_box.append(edit_group)
self._desc_row = Adw.EntryRow(title="Comment")
edit_group.add(self._desc_row)
self._suite_row = Adw.EntryRow(title="Suite / Codename")
edit_group.add(self._suite_row)
self._components_row = Adw.EntryRow(title="Components")
edit_group.add(self._components_row)
self._enabled_row = Adw.SwitchRow(title="Enabled")
edit_group.add(self._enabled_row)
# Signing key row — state driven; refreshed in _populate()
self._signing_group = Adw.PreferencesGroup(title="Signing")
self._signing_row = Adw.ActionRow(title="Signing key")
self._signing_key_btn = Gtk.Button(valign=Gtk.Align.CENTER)
self._signing_key_btn.add_css_class("flat")
self._signing_key_btn.connect("clicked", self._on_signing_key_clicked)
self._signing_row.add_suffix(self._signing_key_btn)
self._signing_group.add(self._signing_row)
detail_box.append(self._signing_group)
# Button row: Remove (left) | Save (right)
btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, margin_top=8)
self._remove_button = Gtk.Button(label="Remove repository…", halign=Gtk.Align.START)
self._remove_button.add_css_class("destructive-action")
self._remove_button.connect("clicked", self._on_remove_clicked)
btn_row.append(self._remove_button)
btn_row.append(Gtk.Box(hexpand=True))
self._save_button = Gtk.Button(label="Save changes", halign=Gtk.Align.END)
self._save_button.add_css_class("suggested-action")
self._save_button.connect("clicked", self._on_save_clicked)
btn_row.append(self._save_button)
detail_box.append(btn_row)
# Toast overlay wraps the stack — set child before adding to self
self._toast_overlay = Adw.ToastOverlay()
self._toast_overlay.set_child(self._stack)
self.append(self._toast_overlay)
def _show_empty_state(self) -> None:
self._stack.set_visible_child_name("empty")
def _populate(self, repo: Repository) -> None:
self._uri_row.set_subtitle(repo.uris[0] if repo.uris else "")
fmt = "DEB822 (.sources)"
if repo.file_format == FileFormat.ONE_LINE:
fmt = "One-line (.list) — will convert to .sources on save"
self._format_row.set_subtitle(fmt)
self._file_row.set_subtitle(str(repo.source_file))
self._desc_row.set_text(repo.description or "")
self._suite_row.set_text(" ".join(repo.suites))
self._components_row.set_text(" ".join(repo.components))
self._enabled_row.set_active(repo.enabled)
self._refresh_signing_row(repo)
# ------------------------------------------------------------------
# Signing key
# ------------------------------------------------------------------
def _refresh_signing_row(self, repo: Repository) -> None:
signed_by = repo.signed_by
if not signed_by:
self._signing_row.set_subtitle("No signing key configured")
self._signing_row.remove_css_class("warning")
self._signing_key_btn.set_label("Add")
elif signed_by.lstrip().startswith("-----BEGIN PGP"):
# Inline OpenPGP key embedded directly in the .sources file
self._signing_row.set_subtitle("Inline OpenPGP key")
self._signing_row.remove_css_class("warning")
self._signing_key_btn.set_label("Edit")
elif not signed_by.startswith("/"):
# Key fingerprint or other non-path reference (e.g. keyring ID)
display = signed_by if len(signed_by) <= 40 else signed_by[:40] + "…"
self._signing_row.set_subtitle(display)
self._signing_row.remove_css_class("warning")
self._signing_key_btn.set_label("Edit")
elif Path(signed_by).exists():
self._signing_row.set_subtitle(Path(signed_by).name)
self._signing_row.remove_css_class("warning")
self._signing_key_btn.set_label("Edit")
else:
self._signing_row.set_subtitle("Key file not found")
self._signing_row.add_css_class("warning")
self._signing_key_btn.set_label("Add")
def _on_signing_key_clicked(self, _btn: Gtk.Button) -> None:
if self._repo is None:
return
if self._repo.signed_by:
from .key_editor_window import KeyEditWindow
win = KeyEditWindow(repo=self._repo, transient_for=self.get_root())
else:
from .key_editor_window import KeyAddWindow
win = KeyAddWindow(repo=self._repo, transient_for=self.get_root())
win.connect("key-saved", self._on_key_saved)
win.present()
def _on_key_saved(self, _win, key_path: str) -> None:
if self._repo is None:
return
self._repo.signed_by = key_path
self._refresh_signing_row(self._repo)
self.emit("repo-saved", self._repo)
# ------------------------------------------------------------------
# Remove
# ------------------------------------------------------------------
def _on_remove_clicked(self, _btn: Gtk.Button) -> None:
if self._repo is None:
return
repo = self._repo
dlg = Gtk.Window(
title="Remove repository",
transient_for=self.get_root(),
modal=True,
resizable=False,
default_width=400,
)
dlg.set_icon_name("io.github.Tecktron.repoman")
box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=12,
margin_top=18,
margin_bottom=18,
margin_start=18,
margin_end=18,
)
box.append(
Gtk.Label(
label=(
f"Remove {repo.display_name}?\n\n"
f"This will delete {repo.source_file.name} from "
f"/etc/apt/sources.list.d/. This cannot be undone."
),
wrap=True,
xalign=0,
max_width_chars=48,
)
)
btn_row = Gtk.Box(spacing=6, halign=Gtk.Align.END, margin_top=6)
cancel_btn = Gtk.Button(label="Cancel")
cancel_btn.connect("clicked", lambda _: dlg.close())
remove_btn = Gtk.Button(label="Remove")
remove_btn.add_css_class("destructive-action")
remove_btn.connect("clicked", lambda _: (dlg.close(), self._do_remove(repo)))
btn_row.append(cancel_btn)
btn_row.append(remove_btn)
box.append(btn_row)
dlg.set_child(box)
from .position import center_on_parent
center_on_parent(dlg)
dlg.present()
def _do_remove(self, repo: Repository) -> None:
payload = json.dumps({"action": "write_files", "writes": [], "deletes": [str(repo.source_file)]})
def _delete() -> None:
result = subprocess.run([PKEXEC, POLKIT_HELPER], input=payload, capture_output=True, text=True)
if result.returncode == 0:
GLib.idle_add(self._on_remove_success, repo)
else:
GLib.idle_add(self._on_remove_failure, result.stderr.strip())
threading.Thread(target=_delete, daemon=True).start()
def _on_remove_success(self, repo: Repository) -> bool:
self.emit("repo-removed", repo)
return GLib.SOURCE_REMOVE
def _on_remove_failure(self, message: str) -> bool:
self._toast_overlay.add_toast(Adw.Toast(title=f"Failed to remove: {message}", timeout=5))
return GLib.SOURCE_REMOVE
# ------------------------------------------------------------------
# Save
# ------------------------------------------------------------------
def _on_copy_uri(self, _button: Gtk.Button) -> None:
if self._repo and self._repo.uris:
self.get_clipboard().set(self._repo.uris[0])
def _on_open_uri(self, _button: Gtk.Button) -> None:
if self._repo and self._repo.uris:
Gtk.show_uri(self.get_root(), self._repo.uris[0], 0)
def _on_save_clicked(self, _button: Gtk.Button) -> None:
if self._repo is None:
return
self._apply_edits_to_repo()
self._save_button.set_sensitive(False)
self._save_button.set_label("Saving…")
threading.Thread(target=self._do_save, daemon=True).start()
def _apply_edits_to_repo(self) -> None:
repo = self._repo
repo.description = self._desc_row.get_text().strip() or None
repo.suites = self._suite_row.get_text().split()
repo.components = self._components_row.get_text().split()
repo.enabled = self._enabled_row.get_active()
def _do_save(self) -> None:
"""Background thread — calls polkit helper."""
repo = self._repo
writes = []
deletes = []
if repo.file_format == FileFormat.ONE_LINE:
new_path, content = convert_to_deb822(repo)
writes.append({"path": str(new_path), "content": content})
deletes.append(str(repo.source_file))
else:
content = repo_to_deb822(repo)
writes.append({"path": str(repo.source_file), "content": content})
payload = json.dumps({"action": "write_files", "writes": writes, "deletes": deletes})
result = subprocess.run(
[PKEXEC, POLKIT_HELPER],
input=payload,
capture_output=True,
text=True,
)
if result.returncode == 0:
GLib.idle_add(self._on_save_success)
else:
GLib.idle_add(self._on_save_failure, result.stderr.strip())
def _on_save_success(self) -> bool:
self._save_button.set_label("Save changes")
self._save_button.set_sensitive(True)
self._toast_overlay.add_toast(Adw.Toast(title="Changes saved", timeout=2))
self.emit("repo-saved", self._repo)
return GLib.SOURCE_REMOVE
def _on_save_failure(self, message: str) -> bool:
self._save_button.set_label("Save changes")
self._save_button.set_sensitive(True)
self._toast_overlay.add_toast(Adw.Toast(title=f"Failed to save: {message}", timeout=5))
return GLib.SOURCE_REMOVE