Files
plugin-helper/src/plugin_helper/tui.py
T
2026-07-01 13:43:39 -07:00

276 lines
9.9 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, Static
from .installer import disable_plugin
from .models import load_lockfile, load_registry
from .operations import enable_disabled_plugin
from .reports import installed_plugins_report
from .state import load_installed_state
@dataclass(frozen=True)
class InstallationChoice:
install_id: str
install_label: str
instance_name: str
instance_path: Path
state_root: Path
class PluginHelperTui(App[int]):
CSS = """
#title {
padding: 0 1;
text-style: bold;
}
#status {
padding: 0 1;
color: $text-muted;
}
"""
BINDINGS = [
Binding("enter", "select", "Select", priority=True),
Binding("space", "toggle_plugin", "Toggle", priority=True),
Binding("d", "disable_all_plugins", "Disable all", priority=True),
Binding("e", "enable_all_plugins", "Enable all", priority=True),
Binding("r", "refresh", "Refresh"),
Binding("b", "back", "Back"),
Binding("q", "quit", "Quit"),
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
]
def __init__(
self,
*,
choices: list[InstallationChoice],
repo_root: Path,
setup_hint: str | None = None,
) -> None:
super().__init__()
self.choices = choices
self.repo_root = repo_root
self.setup_hint = setup_hint
self.mode = "installations"
self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = []
self.status_message = ""
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
yield Static("", id="title")
yield DataTable(id="table")
yield Static("", id="status")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.cursor_type = "row"
self._show_installations()
def action_select(self) -> None:
if self.mode != "installations":
return
index = self._cursor_index(len(self.choices))
if index is None:
return
self.selected_installation = self.choices[index]
self._show_plugins()
def action_back(self) -> None:
if self.mode == "plugins":
self._show_installations()
def action_refresh(self) -> None:
if self.mode == "plugins":
self._show_plugins()
else:
self._show_installations()
def action_toggle_plugin(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
index = self._cursor_index(len(self.plugin_rows))
if index is None:
return
plugin = self.plugin_rows[index]
plugin_id = plugin["id"]
target = self.selected_installation
try:
if plugin["status"] == "enabled":
result = disable_plugin(
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
force=False,
)
if not result["stateUpdated"]:
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
return
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
elif plugin["status"] == "disabled":
result = enable_disabled_plugin(
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
plugin_id=plugin_id,
repo=self.repo_root,
)
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else:
self._set_status(f"Cannot toggle {plugin_id}: unknown status {plugin['status']}.")
return
except Exception as exc:
self._set_status(f"Could not toggle {plugin_id}: {exc}")
return
self._show_plugins(preserve_status=True)
def action_disable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
enabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "enabled"]
changed = 0
errors: list[str] = []
for plugin in enabled:
plugin_id = plugin["id"]
try:
result = disable_plugin(
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
force=False,
)
if result["stateUpdated"]:
changed += 1
else:
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
except Exception as exc:
errors.append(f"{plugin_id}: {exc}")
self._set_bulk_status("Disabled", changed, errors)
self._show_plugins(preserve_status=True)
def action_enable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
changed = 0
errors: list[str] = []
for plugin in disabled:
plugin_id = plugin["id"]
try:
enable_disabled_plugin(
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
plugin_id=plugin_id,
repo=self.repo_root,
)
changed += 1
except Exception as exc:
errors.append(f"{plugin_id}: {exc}")
self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True)
def _show_installations(self) -> None:
self.mode = "installations"
self.plugin_rows = []
self._set_title("Choose Beat Saber Installation")
table = self.query_one(DataTable)
table.clear(columns=True)
table.add_columns("Install", "Version", "Instance Path", "State Dir")
for choice in self.choices:
table.add_row(
choice.install_label,
choice.instance_name,
str(choice.instance_path),
str(choice.state_root),
)
if self.setup_hint:
self._set_status(self.setup_hint)
else:
self._set_status("Enter selects an installation. q quits.")
def _show_plugins(self, *, preserve_status: bool = False) -> None:
if self.selected_installation is None:
self._show_installations()
return
target = self.selected_installation
self.mode = "plugins"
self._set_title(f"{target.install_label} / {target.instance_name}")
table = self.query_one(DataTable)
table.clear(columns=True)
table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset")
try:
lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml")
report = installed_plugins_report(
installed_state=load_installed_state(target.state_root, target.instance_name),
registry=load_registry(self.repo_root / "registry" / "plugins.toml"),
lockfile=lockfile,
)
self.plugin_rows = report["plugins"]
except Exception as exc:
self.plugin_rows = []
if not preserve_status:
self._set_status(f"Could not load plugins: {exc}")
return
for plugin in self.plugin_rows:
table.add_row(
self._status_marker(plugin["status"]),
plugin["name"],
plugin["id"],
plugin["version"],
str(plugin["fileCount"]),
plugin["asset"],
)
if not preserve_status:
if self.plugin_rows:
self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.")
else:
self._set_status("No managed plugins recorded for this installation.")
def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable)
row = table.cursor_coordinate.row
if 0 <= row < row_count:
return row
return None
def _set_title(self, message: str) -> None:
self.query_one("#title", Static).update(message)
def _set_status(self, message: str) -> None:
self.status_message = message
self.query_one("#status", Static).update(message)
def _set_bulk_status(self, verb: str, changed: int, errors: list[str]) -> None:
if errors:
preview = "; ".join(errors[:3])
suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else ""
self._set_status(f"{verb} {changed} plugins; {len(errors)} failed: {preview}{suffix}")
else:
self._set_status(f"{verb} {changed} plugins.")
@staticmethod
def _status_marker(status: str) -> Text:
return Text("[x]" if status == "enabled" else "[ ]", no_wrap=True)
@staticmethod
def _format_skipped(skipped: list[dict[str, str]]) -> str:
if not skipped:
return "no files changed"
return "; ".join(f"{item['path']} {item['reason']}" for item in skipped)