Source code for repoman.ui.detail_pane

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