Boxed dialogs, Enter opens info, edit moved into info #6
No files matched your search
@@ -19,9 +19,10 @@ uv run cv4pve-tui
|
||||
| `S` | shutdown (confirm) |
|
||||
| `x` | stop - hard power-off (confirm) |
|
||||
| `r` | reboot (confirm) |
|
||||
| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
||||
| `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
||||
| `i` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
|
||||
| `Enter` | info: backups, backup job, network (config and runtime IPs) |
|
||||
| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
|
||||
| `Enter` then `b` | in info: backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
|
||||
| `Enter` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
|
||||
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
|
||||
| `g` | refresh |
|
||||
| `c` | switch cv4pve-cli context (cluster) |
|
||||
|
||||
+18
-7
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from rich.text import Text
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult, SuspendNotSupported
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import (
|
||||
@@ -381,6 +382,7 @@ class InfoScreen(ModalScreen):
|
||||
("a", "toggle_all", "All backups"),
|
||||
("n", "net", "Edit network"),
|
||||
("u", "undo", "Undo network"),
|
||||
("e", "edit", "Edit resources"),
|
||||
]
|
||||
DEFAULT_CSS = """
|
||||
InfoScreen DataTable { height: auto; max-height: 12; }
|
||||
@@ -543,6 +545,9 @@ class InfoScreen(ModalScreen):
|
||||
else ""
|
||||
)
|
||||
|
||||
def action_edit(self) -> None:
|
||||
self.app.edit_guest(self.guest)
|
||||
|
||||
def action_toggle_all(self) -> None:
|
||||
self.show_all = not self.show_all
|
||||
self._show_backups()
|
||||
@@ -621,14 +626,18 @@ def _job_text(job: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
class GuestTable(DataTable):
|
||||
"""The guest table; shows its Enter (row selected -> info screen) in the footer."""
|
||||
|
||||
BINDINGS = [Binding("enter", "select_cursor", "Info")] # noqa: RUF012 - Textual idiom
|
||||
|
||||
|
||||
class GuestApp(App):
|
||||
BINDINGS = [ # noqa: RUF012 - Textual idiom
|
||||
("s", "act('start')", "Start"),
|
||||
("S", "act('shutdown')", "Shutdown"),
|
||||
("x", "act('stop')", "Stop"),
|
||||
("r", "act('reboot')", "Reboot"),
|
||||
("e", "edit", "Edit"),
|
||||
("i", "info", "Info"),
|
||||
("t", "shell", "Shell"),
|
||||
("g", "reload", "Refresh"),
|
||||
("c", "context", "Context"),
|
||||
@@ -655,7 +664,7 @@ class GuestApp(App):
|
||||
yield Header()
|
||||
with Vertical(id="guests_section") as section:
|
||||
section.border_title = "Guests"
|
||||
yield DataTable(id="guests", cursor_type="row")
|
||||
yield GuestTable(id="guests", cursor_type="row")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
@@ -841,10 +850,8 @@ class GuestApp(App):
|
||||
lambda ok: self._start_job(guest, name, run) if ok else None,
|
||||
)
|
||||
|
||||
def action_edit(self) -> None:
|
||||
guest = self._selected()
|
||||
if guest is None:
|
||||
return
|
||||
def edit_guest(self, guest: dict) -> None:
|
||||
"""Opened from the info screen (key e)."""
|
||||
if guest["vmid"] in self.busy:
|
||||
self.notify(
|
||||
f"{guest['name']} ({guest['vmid']}) is busy", severity="warning"
|
||||
@@ -857,6 +864,10 @@ class GuestApp(App):
|
||||
if guest is not None:
|
||||
self.push_screen(InfoScreen(guest))
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
if event.data_table.id == "guests": # Enter on a guest row
|
||||
self.action_info()
|
||||
|
||||
def start_backup(self, guest: dict, storage: str) -> None:
|
||||
who = f"{guest['name']} ({guest['vmid']})"
|
||||
if guest["vmid"] in self.busy:
|
||||
|
||||
+58
-10
@@ -378,6 +378,9 @@ async def test_busy_guest_refuses_second_action(fake):
|
||||
|
||||
|
||||
async def open_edit(app, pilot):
|
||||
"""Edit lives in the info screen (user request): Enter, then e."""
|
||||
await settle(app, pilot)
|
||||
await pilot.press("enter")
|
||||
await settle(app, pilot)
|
||||
await pilot.press("e")
|
||||
await settle(app, pilot)
|
||||
@@ -443,7 +446,7 @@ async def test_edit_cancel_changes_nothing(fake):
|
||||
fill(app, cores="8")
|
||||
await press_button(app, pilot, "#cancel")
|
||||
assert fake.sets == [] and fake.resizes == []
|
||||
assert not isinstance(app.screen, ModalScreen)
|
||||
assert isinstance(app.screen, appmod.InfoScreen) # edit closed, back in info
|
||||
|
||||
|
||||
async def test_grow_needs_confirm_then_resizes(fake):
|
||||
@@ -549,17 +552,20 @@ async def test_declining_grow_cancels_whole_edit(fake):
|
||||
assert fake.sets == [] and fake.resizes == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["S", "e"])
|
||||
async def test_escape_closes_modal_without_cli_call(fake, key):
|
||||
@pytest.mark.parametrize("keys", [["S"], ["enter", "e"]])
|
||||
async def test_escape_closes_modal_without_cli_call(fake, keys):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
under = app.screen
|
||||
for key in keys:
|
||||
under = app.screen
|
||||
await pilot.press(key)
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, ModalScreen)
|
||||
assert isinstance(app.screen, (ConfirmScreen, EditScreen))
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, ModalScreen)
|
||||
assert app.screen is under # back where the dialog was opened from
|
||||
assert fake.actions == [] and fake.sets == []
|
||||
|
||||
|
||||
@@ -1047,7 +1053,7 @@ async def open_info(app, pilot, down=0):
|
||||
await settle(app, pilot)
|
||||
for _ in range(down):
|
||||
await pilot.press("down")
|
||||
await pilot.press("i")
|
||||
await pilot.press("enter")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
|
||||
@@ -1165,7 +1171,7 @@ async def test_i_on_empty_table_does_nothing(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
await pilot.press("i")
|
||||
await pilot.press("enter")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, appmod.InfoScreen)
|
||||
assert fake.backups_calls == []
|
||||
@@ -1798,7 +1804,7 @@ async def test_undo_entry_is_per_guest(fake):
|
||||
await pilot.press("escape") # close InfoScreen
|
||||
await settle(app, pilot)
|
||||
await pilot.press("up")
|
||||
await pilot.press("i") # VM 108: no change was made there
|
||||
await pilot.press("enter") # VM 108: no change was made there
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
await pilot.press("u")
|
||||
@@ -1940,7 +1946,7 @@ async def test_edit_screen_fields_have_labels(fake):
|
||||
async def open_each_dialog(app, pilot):
|
||||
"""Yields after each dialog is open; closes it with escape afterwards."""
|
||||
await settle(app, pilot)
|
||||
for keys in (["S"], ["e"], ["c"], ["i", "b"], ["i", "n"]):
|
||||
for keys in (["S"], ["enter", "e"], ["c"], ["enter", "b"], ["enter", "n"]):
|
||||
for k in keys:
|
||||
await pilot.press(k)
|
||||
await settle(app, pilot)
|
||||
@@ -1970,7 +1976,7 @@ async def test_main_table_is_framed_with_title_and_context(fake):
|
||||
assert "mainframe" in str(frame.border_subtitle)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keys", [["e"], ["i", "b"], ["i", "n"]])
|
||||
@pytest.mark.parametrize("keys", [["enter", "e"], ["enter", "b"], ["enter", "n"]])
|
||||
async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, keys):
|
||||
# border titles are parsed as markup; a name like "a[/b]" must be shown literally
|
||||
fake.guests = [dict(VM, name="a[/b]")]
|
||||
@@ -1986,3 +1992,45 @@ async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, ke
|
||||
"a[/b]" in Content.from_markup(title).plain
|
||||
) # what is shown, not how it is stored
|
||||
assert app.is_running
|
||||
|
||||
|
||||
# --- keys: Enter opens the info screen; edit lives there (user request) ------------
|
||||
|
||||
|
||||
async def test_enter_on_a_guest_opens_its_info_screen(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
await pilot.press("down") # CT 100
|
||||
await pilot.press("enter")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
assert app.screen.guest["vmid"] == 100
|
||||
|
||||
|
||||
async def test_i_and_e_do_nothing_on_the_main_screen(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
for key in ("i", "e"):
|
||||
await pilot.press(key)
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, ModalScreen), key
|
||||
|
||||
|
||||
async def test_e_in_info_screen_edits_that_guest_and_returns_to_info(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_edit(app, pilot)
|
||||
fill(app, cores="8")
|
||||
await press_button(app, pilot, "#apply")
|
||||
assert fake.sets == [{"cores": 8}]
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
|
||||
|
||||
async def test_footer_on_main_screen_shows_enter_for_info(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show]
|
||||
assert any(b.key == "enter" and b.description == "Info" for b in shown)
|
||||
Reference in new issue
Block a user