Improve TUI startup and keep plugin toggles responsive
Auto-open the plugin table when only one installation exists, run enable and disable work off the UI thread with immediate status feedback, and skip the back action for single-instance setups. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
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
|
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
|
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.
|
needed.
|
||||||
|
|
||||||
The individual subcommands are mostly for automation and debugging. If you use
|
The individual subcommands are mostly for automation and debugging. If you use
|
||||||
|
|||||||
+66
-29
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -64,6 +65,7 @@ class PluginHelperTui(App[int]):
|
|||||||
self.selected_installation: InstallationChoice | None = None
|
self.selected_installation: InstallationChoice | None = None
|
||||||
self.plugin_rows: list[dict[str, Any]] = []
|
self.plugin_rows: list[dict[str, Any]] = []
|
||||||
self.status_message = ""
|
self.status_message = ""
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header(show_clock=False)
|
yield Header(show_clock=False)
|
||||||
@@ -75,6 +77,10 @@ class PluginHelperTui(App[int]):
|
|||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
table = self.query_one(DataTable)
|
table = self.query_one(DataTable)
|
||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
|
if len(self.choices) == 1:
|
||||||
|
self.selected_installation = self.choices[0]
|
||||||
|
self._show_plugins()
|
||||||
|
return
|
||||||
self._show_installations()
|
self._show_installations()
|
||||||
|
|
||||||
def action_select(self) -> None:
|
def action_select(self) -> None:
|
||||||
@@ -88,16 +94,20 @@ class PluginHelperTui(App[int]):
|
|||||||
|
|
||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
if self.mode == "plugins":
|
if self.mode == "plugins":
|
||||||
|
if len(self.choices) == 1:
|
||||||
|
return
|
||||||
self._show_installations()
|
self._show_installations()
|
||||||
|
|
||||||
def action_refresh(self) -> None:
|
def action_refresh(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
if self.mode == "plugins":
|
if self.mode == "plugins":
|
||||||
self._show_plugins()
|
self._show_plugins()
|
||||||
else:
|
else:
|
||||||
self._show_installations()
|
self._show_installations()
|
||||||
|
|
||||||
def action_toggle_plugin(self) -> None:
|
async def action_toggle_plugin(self) -> None:
|
||||||
if self.mode != "plugins" or self.selected_installation is None:
|
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||||
return
|
return
|
||||||
index = self._cursor_index(len(self.plugin_rows))
|
index = self._cursor_index(len(self.plugin_rows))
|
||||||
if index is None:
|
if index is None:
|
||||||
@@ -105,27 +115,32 @@ class PluginHelperTui(App[int]):
|
|||||||
plugin = self.plugin_rows[index]
|
plugin = self.plugin_rows[index]
|
||||||
plugin_id = plugin["id"]
|
plugin_id = plugin["id"]
|
||||||
target = self.selected_installation
|
target = self.selected_installation
|
||||||
|
self._busy = True
|
||||||
try:
|
try:
|
||||||
if plugin["status"] == "enabled":
|
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_name,
|
||||||
target.instance_path,
|
target.instance_path,
|
||||||
target.state_root,
|
target.state_root,
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force=False,
|
False,
|
||||||
)
|
)
|
||||||
if not result["stateUpdated"]:
|
if not result["stateUpdated"]:
|
||||||
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
|
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
|
||||||
return
|
return
|
||||||
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
|
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
|
||||||
elif plugin["status"] == "disabled":
|
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=target.instance_name,
|
||||||
instance_path=target.instance_path,
|
instance_path=target.instance_path,
|
||||||
state_root=target.state_root,
|
state_root=target.state_root,
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
repo=self.repo_root,
|
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.")
|
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
|
||||||
else:
|
else:
|
||||||
@@ -134,10 +149,12 @@ class PluginHelperTui(App[int]):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._set_status(f"Could not toggle {plugin_id}: {exc}")
|
self._set_status(f"Could not toggle {plugin_id}: {exc}")
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
self._show_plugins(preserve_status=True)
|
self._show_plugins(preserve_status=True)
|
||||||
|
|
||||||
def action_disable_all_plugins(self) -> None:
|
async def action_disable_all_plugins(self) -> None:
|
||||||
if self.mode != "plugins" or self.selected_installation is None:
|
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||||
return
|
return
|
||||||
target = self.selected_installation
|
target = self.selected_installation
|
||||||
enabled = [
|
enabled = [
|
||||||
@@ -148,32 +165,41 @@ class PluginHelperTui(App[int]):
|
|||||||
skipped_bsipa = any(
|
skipped_bsipa = any(
|
||||||
plugin["status"] == "enabled" and plugin["id"] == BSIPA_PLUGIN_ID for plugin in self.plugin_rows
|
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
|
changed = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for plugin in enabled:
|
try:
|
||||||
plugin_id = plugin["id"]
|
for plugin in enabled:
|
||||||
try:
|
plugin_id = plugin["id"]
|
||||||
result = disable_plugin(
|
try:
|
||||||
target.instance_name,
|
result = await asyncio.to_thread(
|
||||||
target.instance_path,
|
disable_plugin,
|
||||||
target.state_root,
|
target.instance_name,
|
||||||
plugin_id,
|
target.instance_path,
|
||||||
force=False,
|
target.state_root,
|
||||||
)
|
plugin_id,
|
||||||
if result["stateUpdated"]:
|
False,
|
||||||
changed += 1
|
)
|
||||||
else:
|
if result["stateUpdated"]:
|
||||||
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
|
changed += 1
|
||||||
except Exception as exc:
|
else:
|
||||||
errors.append(f"{plugin_id}: {exc}")
|
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:
|
if skipped_bsipa and not errors:
|
||||||
self._set_status(f"Disabled {changed} plugins (bsipa kept enabled).")
|
self._set_status(f"Disabled {changed} plugins (bsipa kept enabled).")
|
||||||
else:
|
else:
|
||||||
self._set_bulk_status("Disabled", changed, errors)
|
self._set_bulk_status("Disabled", changed, errors)
|
||||||
self._show_plugins(preserve_status=True)
|
self._show_plugins(preserve_status=True)
|
||||||
|
|
||||||
def action_enable_all_plugins(self) -> None:
|
async def action_enable_all_plugins(self) -> None:
|
||||||
if self.mode != "plugins" or self.selected_installation is None:
|
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||||
return
|
return
|
||||||
target = self.selected_installation
|
target = self.selected_installation
|
||||||
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
|
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.")
|
self._set_status("No disabled plugins to enable.")
|
||||||
return
|
return
|
||||||
plugin_ids = [plugin["id"] for plugin in disabled]
|
plugin_ids = [plugin["id"] for plugin in disabled]
|
||||||
|
self._busy = True
|
||||||
|
self._set_status(f"Enabling {len(plugin_ids)} plugins...")
|
||||||
try:
|
try:
|
||||||
result = enable_disabled_plugins(
|
result = await asyncio.to_thread(
|
||||||
|
enable_disabled_plugins,
|
||||||
instance=target.instance_name,
|
instance=target.instance_name,
|
||||||
instance_path=target.instance_path,
|
instance_path=target.instance_path,
|
||||||
state_root=target.state_root,
|
state_root=target.state_root,
|
||||||
plugin_ids=plugin_ids,
|
plugin_ids=plugin_ids,
|
||||||
repo=self.repo_root,
|
repo=self.repo_root,
|
||||||
progress=lambda message: self._set_status(f"Bootstrapping: {message}"),
|
progress=self._bootstrap_progress,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._set_status(f"Could not enable plugins: {exc}")
|
self._set_status(f"Could not enable plugins: {exc}")
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
changed = len(result["enabled"])
|
changed = len(result["enabled"])
|
||||||
errors = [f"{item['plugin']}: {item['error']}" for item in result["errors"]]
|
errors = [f"{item['plugin']}: {item['error']}" for item in result["errors"]]
|
||||||
self._set_bulk_status("Enabled", changed, errors)
|
self._set_bulk_status("Enabled", changed, errors)
|
||||||
self._show_plugins(preserve_status=True)
|
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:
|
def _show_installations(self) -> None:
|
||||||
self.mode = "installations"
|
self.mode = "installations"
|
||||||
self.plugin_rows = []
|
self.plugin_rows = []
|
||||||
@@ -253,7 +287,10 @@ class PluginHelperTui(App[int]):
|
|||||||
)
|
)
|
||||||
if not preserve_status:
|
if not preserve_status:
|
||||||
if self.plugin_rows:
|
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:
|
else:
|
||||||
self._set_status("No managed plugins recorded for this installation.")
|
self._set_status("No managed plugins recorded for this installation.")
|
||||||
|
|
||||||
|
|||||||
@@ -1496,16 +1496,27 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(table.row_count, 2)
|
self.assertEqual(table.row_count, 2)
|
||||||
self.assertEqual(app.mode, "installations")
|
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:
|
async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
self.assertEqual(app.mode, "plugins")
|
||||||
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
|
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
|
||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
|
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
|
||||||
await pilot.press("space")
|
await pilot.press("space")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||||
updated = load_installed_state(state, "1.40.8")
|
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)
|
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
self.assertEqual(app.mode, "plugins")
|
||||||
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
|
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
|
||||||
await pilot.press("space")
|
await pilot.press("space")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||||
updated = load_installed_state(state, "1.40.8")
|
updated = load_installed_state(state, "1.40.8")
|
||||||
@@ -1531,8 +1543,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.press("d")
|
await pilot.press("d")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||||
updated = load_installed_state(state, "1.40.8")
|
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)
|
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.press("e")
|
await pilot.press("e")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||||
updated = load_installed_state(state, "1.40.8")
|
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)
|
app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.press("space")
|
await pilot.press("space")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
|
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
|
||||||
updated = load_installed_state(state, "1.40.8")
|
updated = load_installed_state(state, "1.40.8")
|
||||||
|
|||||||
Reference in New Issue
Block a user