Add profile-aware plugin TUI

This commit is contained in:
pleb
2026-07-01 11:44:03 -07:00
parent 13b1840ba0
commit 69f9dbd9b1
10 changed files with 876 additions and 265 deletions
+98 -197
View File
@@ -4,9 +4,9 @@ import argparse
import json
import sys
from pathlib import Path
from typing import Any, Callable
from typing import Any
from .config import instances_roots, repo_root, state_root
from .config import Profile, repo_root, resolve_runtime_config
from .bootstrap import run_bootstrap
from .bsipa import check_bsipa_health
from .checker import check_lock
@@ -14,8 +14,9 @@ from .github import fetch_releases
from .installer import apply_plan, disable_plugin, uninstall_plugin
from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry
from .models import Lockfile, Registry
from .operations import enable_disabled_plugin
from .planner import create_plan
from .reports import installed_plugins_report, print_installed_plugins
from .scanner import scan_instance
from .state import load_installed_state
from .updates import check_updates
@@ -26,79 +27,6 @@ def _json(data: Any) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def installed_plugins_report(
*,
installed_state: dict[str, Any],
registry: Registry,
lockfile: Lockfile,
) -> dict[str, Any]:
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
state_plugins = [
(plugin_id, plugin_state, "enabled")
for plugin_id, plugin_state in installed_state.get("plugins", {}).items()
]
state_plugins.extend(
(plugin_id, plugin_state, "disabled")
for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items()
)
for plugin_id, plugin_state, status in sorted(state_plugins):
registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", [])
plugins.append(
{
"id": plugin_id,
"name": registry_plugin.name if registry_plugin else plugin_id,
"version": locked.tag if locked and locked.tag else "(not locked)",
"asset": locked.asset if locked and locked.asset else "(unknown)",
"repo": (locked.repo if locked and locked.repo else None)
or (registry_plugin.repo if registry_plugin else None)
or "(unknown)",
"installedAt": plugin_state.get("installedAt", "(unknown)"),
"disabledAt": plugin_state.get("disabledAt"),
"status": status,
"fileCount": len(files),
"files": files,
}
)
return {
"instance": installed_state.get("instance", lockfile.instance),
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
"plugins": plugins,
}
def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})")
if not plugins:
print("No plugins have been installed by plugin-helper yet.")
return
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["status"],
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["disabledAt"] or plugin["installedAt"],
)
for plugin in plugins
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
print(header)
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def print_updates(report: dict[str, Any]) -> None:
plugins = report["plugins"]
summary = report["summary"]
@@ -135,6 +63,8 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
parser.add_argument("--state-dir", default=default, help="plugin-helper state directory")
parser.add_argument("--profile", default=default, help="Configured profile id from plugin-helper.local.toml")
parser.add_argument("--config", default=default, help="Path to plugin-helper TOML config")
def build_parser() -> argparse.ArgumentParser:
@@ -295,120 +225,101 @@ def _common_parent() -> argparse.ArgumentParser:
return parent
def _ask_choice(
def _menu_profiles(
*,
title: str,
choices: list[tuple[str, str] | tuple[str, str, str]],
input_func: Callable[[str], str] | None = None,
) -> str | None:
ask = input_func or input
print()
print(title)
for index, choice in enumerate(choices, start=1):
label = choice[1]
print(f" {index}. {label}")
if len(choice) > 2:
print(f" {choice[2]}")
print(" q. Quit")
while True:
answer = ask("> ").strip().lower()
if answer in {"q", "quit", "exit"}:
return None
if answer.isdigit():
index = int(answer)
if 1 <= index <= len(choices):
return choices[index - 1][0]
print("Choose a listed number, or q to quit.")
def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], str] | None = None) -> int:
ask = input_func or input
instances = list_instances(inst_roots)
if not instances:
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
return 1
instances_by_choice = {str(index): item for index, item in enumerate(instances, start=1)}
instance_choices = [(str(index), f"{item.name} {item.path}") for index, item in enumerate(instances, start=1)]
action_choices = [
("installed", "Show managed installs", "Lists plugins recorded in plugin-helper state with locked versions and files."),
("updates", "Check locked plugin updates", "Looks at GitHub releases for newer assets matching locked plugins."),
("scan", "Scan installed files", "Counts files currently present in Plugins/, Libs/, and IPA/Pending/."),
("check", "Check lockfile and assets", "Validates registry entries, lockfile data, local assets, and SHA-256 values."),
("bootstrap", "Bootstrap BSIPA", "Fetches the locked BSIPA archive, installs it, runs IPA.exe -n, and records bootstrap files."),
("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."),
("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."),
("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."),
("disable", "Disable managed plugin", "Removes one recorded plugin from the game while keeping helper state and assets."),
("enable", "Enable managed plugin", "Reinstalls one disabled locked plugin from local assets."),
("backup-userdata", "Back up UserData", "Copies UserData and AppData into the adjacent backups repo."),
("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."),
("change", "Choose another version", "Returns to the Beat Saber version picker."),
profiles: tuple[Profile, ...],
selected_profile: Profile | None,
instances_roots: list[Path],
state_root: Path,
use_config_profiles: bool,
) -> list[Profile]:
if selected_profile:
return [
Profile(
id=selected_profile.id if len(instances_roots) == 1 else f"{selected_profile.id}-{index}",
label=selected_profile.label,
instances_root=root,
state_dir=state_root,
)
for index, root in enumerate(instances_roots, start=1)
]
if use_config_profiles and profiles:
return list(profiles)
return [
Profile(
id=f"root-{index}",
label=str(root),
instances_root=root,
state_dir=state_root,
)
for index, root in enumerate(instances_roots, start=1)
]
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
def _run_menu(
*,
runtime: Any,
explicit_instances_root: bool,
explicit_state_dir: bool,
) -> int:
try:
from .tui import InstallationChoice, PluginHelperTui
except ImportError as exc:
print(f"Textual is required for the menu TUI: {exc}")
print("Install project dependencies, for example: python -m pip install -e .")
return 1
profiles = _menu_profiles(
profiles=runtime.profiles,
selected_profile=runtime.selected_profile,
instances_roots=runtime.instances_roots,
state_root=runtime.state_root,
use_config_profiles=runtime.config_loaded and not explicit_instances_root and not explicit_state_dir,
)
if selected_instance_key is None:
return 0
selected_instance = instances_by_choice[selected_instance_key]
while True:
selected_action = _ask_choice(
title=f"Choose action for {selected_instance.name}",
choices=action_choices,
input_func=ask,
)
if selected_action is None:
return 0
if selected_action == "change":
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
choices: list[InstallationChoice] = []
for profile in profiles:
for instance in list_instances(profile.instances_root):
choices.append(
InstallationChoice(
profile_id=profile.id,
profile_label=profile.label,
instance_name=instance.name,
instance_path=instance.path,
state_root=profile.state_dir,
)
)
if selected_instance_key is None:
return 0
selected_instance = instances_by_choice[selected_instance_key]
continue
if not choices:
searched = ", ".join(str(profile.instances_root) for profile in profiles)
print(f"No Beat Saber instances found under {searched}")
return 1
command = [
"--instances-root",
str(selected_instance.path.parent),
"--state-dir",
str(st_root),
selected_action,
]
if selected_action == "apply":
plan_path = ask("Plan path> ").strip()
if not plan_path:
print("No plan path entered.")
continue
command.append(plan_path)
elif selected_action in {"disable", "enable"}:
plugin_id = ask("Plugin id> ").strip()
if not plugin_id:
print("No plugin id entered.")
continue
command.extend(["--instance", selected_instance.name, plugin_id])
else:
command.extend(["--instance", selected_instance.name])
print()
status = run(command)
print(f"Command exited with status {status}")
setup_hint = None
if not runtime.config_loaded and not runtime.selected_profile and not explicit_instances_root and not explicit_state_dir:
setup_hint = (
f"No {runtime.config_path.name} found; using default roots and default state. "
f"Copy plugin-helper.toml.example to {runtime.config_path.name} to map each install to a state dir."
)
app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint)
result = app.run()
return int(result or 0)
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
inst_roots = instances_roots(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None))
try:
explicit_instances_root = getattr(args, "instances_root", None) is not None
explicit_state_dir = getattr(args, "state_dir", None) is not None
runtime = resolve_runtime_config(
instances_root_value=getattr(args, "instances_root", None),
state_dir_value=getattr(args, "state_dir", None),
profile_id=getattr(args, "profile", None),
config_path=getattr(args, "config", None),
)
inst_roots = runtime.instances_roots
st_root = runtime.state_root
if args.command == "instances":
found = list_instances(inst_roots)
if not found:
@@ -427,7 +338,11 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "menu":
return _run_menu(inst_roots, st_root)
return _run_menu(
runtime=runtime,
explicit_instances_root=explicit_instances_root,
explicit_state_dir=explicit_state_dir,
)
if args.command == "scan":
instance = get_instance(inst_roots, args.instance)
@@ -617,30 +532,16 @@ def run(argv: list[str] | None = None) -> int:
if args.command == "enable":
instance = get_instance(inst_roots, args.instance)
installed_state = load_installed_state(st_root, args.instance)
if args.plugin not in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {args.plugin}")
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
lockfile = load_lockfile(lock_path)
if not any(plugin.id == args.plugin for plugin in lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {args.plugin}")
plan, path = create_plan(
result = enable_disabled_plugin(
instance=args.instance,
instance_path=instance.path,
beat_saber_version=lockfile.beat_saber_version,
registry=load_registry(registry_path),
lockfile=lockfile,
state_root=st_root,
repo_root=root,
selected={args.plugin},
plugin_id=args.plugin,
registry=args.registry,
lockfile=args.lockfile,
)
result = apply_plan(plan, st_root)
print(f"Enabled: {args.plugin}")
print(f"Plan: {path}")
print(f"Plan: {result['planPath']}")
print(f"Applied: {len(result['applied'])}")
print(f"State: {result['statePath']}")
return 0
+110 -3
View File
@@ -1,13 +1,39 @@
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT)
DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
@dataclass(frozen=True)
class Profile:
id: str
label: str
instances_root: Path
state_dir: Path
@dataclass(frozen=True)
class RuntimeConfig:
instances_roots: list[Path]
state_root: Path
profiles: tuple[Profile, ...]
selected_profile: Profile | None
config_path: Path
config_loaded: bool
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def instances_root(value: str | None = None) -> Path:
@@ -23,11 +49,92 @@ def instances_roots(value: str | None = None) -> list[Path]:
def state_root(value: str | None = None) -> Path:
if value:
return Path(value).expanduser()
return _resolve_path(value, repo_root())
xdg_state = os.environ.get("XDG_STATE_HOME")
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
return base / "plugin-helper"
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def default_config_path(root: Path | None = None) -> Path:
return (root or repo_root()) / LOCAL_CONFIG_NAME
def _resolve_path(value: str | Path, base: Path) -> Path:
path = Path(value).expanduser()
if path.is_absolute():
return path
return (base / path).resolve()
def load_profiles(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[tuple[Profile, ...], Path, bool]:
repo = root or repo_root()
path = _resolve_path(config_path, repo) if config_path else default_config_path(repo)
if not path.exists():
if config_path:
raise FileNotFoundError(f"plugin-helper config not found: {path}")
return (), path, False
with path.open("rb") as handle:
data: dict[str, Any] = tomllib.load(handle)
profiles: list[Profile] = []
seen: set[str] = set()
base = path.parent
for item in data.get("profiles", []):
profile_id = item["id"]
if profile_id in seen:
raise ValueError(f"{path}: duplicate profile id: {profile_id}")
seen.add(profile_id)
profiles.append(
Profile(
id=profile_id,
label=item.get("label", profile_id),
instances_root=_resolve_path(item["instances_root"], base),
state_dir=_resolve_path(item["state_dir"], base),
)
)
return tuple(profiles), path, True
def profile_by_id(profiles: tuple[Profile, ...], profile_id: str) -> Profile:
for profile in profiles:
if profile.id == profile_id:
return profile
available = ", ".join(profile.id for profile in profiles) or "(none)"
raise KeyError(f"unknown profile: {profile_id}; available profiles: {available}")
def resolve_runtime_config(
*,
instances_root_value: str | None = None,
state_dir_value: str | None = None,
profile_id: str | None = None,
config_path: str | Path | None = None,
root: Path | None = None,
) -> RuntimeConfig:
repo = root or repo_root()
profiles, loaded_path, loaded = load_profiles(config_path, root=repo)
selected = profile_by_id(profiles, profile_id) if profile_id else None
if instances_root_value:
resolved_instances = instances_roots(instances_root_value)
elif selected:
resolved_instances = [selected.instances_root]
else:
resolved_instances = instances_roots(None)
if state_dir_value:
resolved_state = _resolve_path(state_dir_value, repo)
elif selected:
resolved_state = selected.state_dir
else:
resolved_state = state_root(None)
return RuntimeConfig(
instances_roots=resolved_instances,
state_root=resolved_state,
profiles=profiles,
selected_profile=selected,
config_path=loaded_path,
config_loaded=loaded,
)
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .config import repo_root
from .installer import apply_plan
from .models import load_lockfile, load_registry
from .planner import create_plan
from .state import load_installed_state
def enable_disabled_plugin(
*,
instance: str,
instance_path: Path,
state_root: Path,
plugin_id: str,
registry: str = "registry/plugins.toml",
lockfile: str | None = None,
repo: Path | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
if plugin_id not in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
root = repo or repo_root()
registry_path = (root / registry).resolve() if not Path(registry).is_absolute() else Path(registry)
lock_path = Path(lockfile) if lockfile else root / "locks" / f"{instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
loaded_lockfile = load_lockfile(lock_path)
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}")
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=load_registry(registry_path),
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
result = apply_plan(plan, state_root)
return {"planPath": str(path), **result}
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from .models import Lockfile, Registry
def installed_plugins_report(
*,
installed_state: dict[str, Any],
registry: Registry,
lockfile: Lockfile,
) -> dict[str, Any]:
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
state_plugins = [
(plugin_id, plugin_state, "enabled")
for plugin_id, plugin_state in installed_state.get("plugins", {}).items()
]
state_plugins.extend(
(plugin_id, plugin_state, "disabled")
for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items()
)
for plugin_id, plugin_state, status in sorted(state_plugins):
registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", [])
plugins.append(
{
"id": plugin_id,
"name": registry_plugin.name if registry_plugin else plugin_id,
"version": locked.tag if locked and locked.tag else "(not locked)",
"asset": locked.asset if locked and locked.asset else "(unknown)",
"repo": (locked.repo if locked and locked.repo else None)
or (registry_plugin.repo if registry_plugin else None)
or "(unknown)",
"installedAt": plugin_state.get("installedAt", "(unknown)"),
"disabledAt": plugin_state.get("disabledAt"),
"status": status,
"fileCount": len(files),
"files": files,
}
)
return {
"instance": installed_state.get("instance", lockfile.instance),
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
"plugins": plugins,
}
def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})")
if not plugins:
print("No plugins have been installed by plugin-helper yet.")
return
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["status"],
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["disabledAt"] or plugin["installedAt"],
)
for plugin in plugins
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
print(header)
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
+275
View File
@@ -0,0 +1,275 @@
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:
profile_id: str
profile_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("Profile", "Version", "Instance Path", "State Dir")
for choice in self.choices:
table.add_row(
choice.profile_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.profile_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)