diff --git a/README.md b/README.md index 1001cbf..7a817ef 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ The menu reads `plugin-helper.local.toml` when present, shows each discovered Beat Saber install with its resolved state directory, and lets you toggle managed plugins with arrow keys and Space. In the plugin table, use `d` to disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all -currently disabled managed plugins. Re-enabling a plugin auto-bootstraps BSIPA when +currently disabled managed plugins. With a single Beat Saber installation, the +menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed. The individual subcommands are mostly for automation and debugging. If you use diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index 9e69a43..82c096e 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass from pathlib import Path from typing import Any @@ -64,6 +65,7 @@ class PluginHelperTui(App[int]): self.selected_installation: InstallationChoice | None = None self.plugin_rows: list[dict[str, Any]] = [] self.status_message = "" + self._busy = False def compose(self) -> ComposeResult: yield Header(show_clock=False) @@ -75,6 +77,10 @@ class PluginHelperTui(App[int]): def on_mount(self) -> None: table = self.query_one(DataTable) table.cursor_type = "row" + if len(self.choices) == 1: + self.selected_installation = self.choices[0] + self._show_plugins() + return self._show_installations() def action_select(self) -> None: @@ -88,16 +94,20 @@ class PluginHelperTui(App[int]): def action_back(self) -> None: if self.mode == "plugins": + if len(self.choices) == 1: + return self._show_installations() def action_refresh(self) -> None: + if self._busy: + return 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: + async def action_toggle_plugin(self) -> None: + if self._busy or self.mode != "plugins" or self.selected_installation is None: return index = self._cursor_index(len(self.plugin_rows)) if index is None: @@ -105,27 +115,32 @@ class PluginHelperTui(App[int]): plugin = self.plugin_rows[index] plugin_id = plugin["id"] target = self.selected_installation + self._busy = True try: if plugin["status"] == "enabled": - result = disable_plugin( + self._set_status(f"Disabling {plugin_id}...") + result = await asyncio.to_thread( + disable_plugin, target.instance_name, target.instance_path, target.state_root, plugin_id, - force=False, + 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( + self._set_status(f"Enabling {plugin_id}...") + result = await asyncio.to_thread( + 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, - progress=lambda message: self._set_status(f"Bootstrapping: {message}"), + progress=self._bootstrap_progress, ) self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.") else: @@ -134,10 +149,12 @@ class PluginHelperTui(App[int]): except Exception as exc: self._set_status(f"Could not toggle {plugin_id}: {exc}") return + finally: + self._busy = False self._show_plugins(preserve_status=True) - def action_disable_all_plugins(self) -> None: - if self.mode != "plugins" or self.selected_installation is None: + async def action_disable_all_plugins(self) -> None: + if self._busy or self.mode != "plugins" or self.selected_installation is None: return target = self.selected_installation enabled = [ @@ -148,32 +165,41 @@ class PluginHelperTui(App[int]): skipped_bsipa = any( plugin["status"] == "enabled" and plugin["id"] == BSIPA_PLUGIN_ID for plugin in self.plugin_rows ) + if not enabled: + self._set_status("No enabled plugins to disable.") + return + self._busy = True + self._set_status(f"Disabling {len(enabled)} plugins...") 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}") + try: + for plugin in enabled: + plugin_id = plugin["id"] + try: + result = await asyncio.to_thread( + disable_plugin, + target.instance_name, + target.instance_path, + target.state_root, + plugin_id, + 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}") + finally: + self._busy = False if skipped_bsipa and not errors: self._set_status(f"Disabled {changed} plugins (bsipa kept enabled).") else: 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: + async def action_enable_all_plugins(self) -> None: + if self._busy or 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"] @@ -181,23 +207,31 @@ class PluginHelperTui(App[int]): self._set_status("No disabled plugins to enable.") return plugin_ids = [plugin["id"] for plugin in disabled] + self._busy = True + self._set_status(f"Enabling {len(plugin_ids)} plugins...") try: - result = enable_disabled_plugins( + result = await asyncio.to_thread( + enable_disabled_plugins, instance=target.instance_name, instance_path=target.instance_path, state_root=target.state_root, plugin_ids=plugin_ids, repo=self.repo_root, - progress=lambda message: self._set_status(f"Bootstrapping: {message}"), + progress=self._bootstrap_progress, ) except Exception as exc: self._set_status(f"Could not enable plugins: {exc}") return + finally: + self._busy = False changed = len(result["enabled"]) errors = [f"{item['plugin']}: {item['error']}" for item in result["errors"]] self._set_bulk_status("Enabled", changed, errors) self._show_plugins(preserve_status=True) + def _bootstrap_progress(self, message: str) -> None: + self.call_from_thread(self._set_status, f"Bootstrapping: {message}") + def _show_installations(self) -> None: self.mode = "installations" self.plugin_rows = [] @@ -253,7 +287,10 @@ class PluginHelperTui(App[int]): ) if not preserve_status: if self.plugin_rows: - self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.") + back_hint = "" if len(self.choices) == 1 else " b returns to installations." + self._set_status( + f"Space toggles selected. d disables all. e enables all.{back_hint}" + ) else: self._set_status("No managed plugins recorded for this installation.") diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index e589649..c09053f 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -1496,16 +1496,27 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(table.row_count, 2) self.assertEqual(app.mode, "installations") + async def test_single_instance_skips_installation_picker(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, _instance, _state = _make_tui_fixture(Path(tmp)) + + async with app.run_test(): + self.assertEqual(app.mode, "plugins") + self.assertEqual(app.selected_installation, app.choices[0]) + table = app.query_one(DataTable) + self.assertEqual(table.row_count, 1) + async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None: with tempfile.TemporaryDirectory() as tmp: app, instance, state = _make_tui_fixture(Path(tmp)) async with app.run_test() as pilot: - await pilot.press("enter") + self.assertEqual(app.mode, "plugins") self.assertEqual(app.plugin_rows[0]["status"], "enabled") table = app.query_one(DataTable) self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True)) await pilot.press("space") + await pilot.pause() self.assertFalse((instance / "Plugins" / "Example.dll").exists()) updated = load_installed_state(state, "1.40.8") @@ -1517,9 +1528,10 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): app, instance, state = _make_tui_fixture(Path(tmp), disabled=True) async with app.run_test() as pilot: - await pilot.press("enter") + self.assertEqual(app.mode, "plugins") self.assertEqual(app.plugin_rows[0]["status"], "disabled") await pilot.press("space") + await pilot.pause() self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") updated = load_installed_state(state, "1.40.8") @@ -1531,8 +1543,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): app, instance, state = _make_tui_fixture(Path(tmp)) async with app.run_test() as pilot: - await pilot.press("enter") await pilot.press("d") + await pilot.pause() self.assertFalse((instance / "Plugins" / "Example.dll").exists()) updated = load_installed_state(state, "1.40.8") @@ -1545,8 +1557,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): app, instance, state = _make_tui_fixture(Path(tmp), disabled=True) async with app.run_test() as pilot: - await pilot.press("enter") await pilot.press("e") + await pilot.pause() self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") updated = load_installed_state(state, "1.40.8") @@ -1559,8 +1571,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True) async with app.run_test() as pilot: - await pilot.press("enter") await pilot.press("space") + await pilot.pause() self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll") updated = load_installed_state(state, "1.40.8")