diff --git a/README.md b/README.md index 912baef..568a45d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Services tab: s start S stop R restart + (or use the Start / Stop / Restart buttons) ``` ## Requirements diff --git a/dsmview/__init__.py b/dsmview/__init__.py index 493f741..260c070 100644 --- a/dsmview/__init__.py +++ b/dsmview/__init__.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/dsmview/ui/app.py b/dsmview/ui/app.py index f83cabf..5dc881b 100644 --- a/dsmview/ui/app.py +++ b/dsmview/ui/app.py @@ -98,6 +98,8 @@ class DsmviewApp(App): def action_switch_tab(self, tab_id: str) -> None: self.query_one(TabbedContent).active = tab_id + if tab_id == "services": + self.services_tab.focus_table() def action_filter(self, name: str) -> None: self.query_one(TabbedContent).active = "logs" diff --git a/dsmview/ui/tabs/services.py b/dsmview/ui/tabs/services.py index ddfd934..8951e6d 100644 --- a/dsmview/ui/tabs/services.py +++ b/dsmview/ui/tabs/services.py @@ -1,7 +1,7 @@ from __future__ import annotations from textual.app import ComposeResult -from textual.containers import Container, Horizontal +from textual.containers import Container, Horizontal, VerticalScroll from textual.screen import ModalScreen from textual.widgets import Button, DataTable, Static @@ -54,28 +54,63 @@ class ConfirmActionScreen(ModalScreen[bool]): self.dismiss(event.button.id == "confirm-yes") -class ServicesTab(Container): - """Interactive service list with start/stop/restart actions.""" +class ServicesTab(VerticalScroll): + """Interactive service list with visible start/stop/restart controls.""" + + DEFAULT_CSS = """ + ServicesTab { + padding: 0 1; + } + ServicesTab #services-table { + height: 1fr; + min-height: 10; + } + ServicesTab #services-buttons { + height: auto; + margin-top: 1; + } + """ def __init__(self, executor: Executor) -> None: super().__init__() self.executor = executor self._services: list[ServiceInfo] = [] + self._last_cursor_row: int = 0 + self._last_selected_unit: str = "" def compose(self) -> ComposeResult: with Container(classes="panel"): yield Static( - "SERVICES — ↑/↓ select [s]tart [S]top [R]estart", + "SERVICES — ↑/↓ select, then Start / Stop / Restart", classes="panel-title", id="services-header", ) self.status_bar = Static("loading...", id="services-status") yield self.status_bar - self.table = DataTable(zebra_stripes=True, show_cursor=True, cursor_type="row") + self.table = DataTable( + zebra_stripes=True, + show_cursor=True, + cursor_type="row", + id="services-table", + ) self.table.add_columns("SERVICE", "STATUS", "DESCRIPTION") yield self.table + with Horizontal(id="services-buttons"): + yield Button("Start", id="btn-start", variant="success") + yield Button("Stop", id="btn-stop", variant="error") + yield Button("Restart", id="btn-restart", variant="warning") def update(self, services: list[ServiceInfo]) -> None: + # Remember what was selected so we can restore the cursor after the + # periodic refresh re-renders the table. + if self.table.row_count: + coord = self.table.cursor_coordinate + self._last_cursor_row = coord.row + if 0 <= coord.row < len(self._services): + self._last_selected_unit = self._services[coord.row].unit + else: + self._last_selected_unit = "" + self._services = services self.table.clear() running = sum(1 for s in services if s.running) @@ -89,6 +124,23 @@ class ServicesTab(Container): ) self.table.add_row(s.name, status, s.description) + # Restore cursor: prefer the previously selected unit, otherwise the + # previously selected row index. + new_row = -1 + if self._last_selected_unit: + for i, s in enumerate(services): + if s.unit == self._last_selected_unit: + new_row = i + break + if new_row < 0 and 0 <= self._last_cursor_row < len(services): + new_row = self._last_cursor_row + if new_row >= 0: + self.table.move_cursor(row=new_row) + self._update_action_status() + + def focus_table(self) -> None: + self.table.focus() + def action_start_service(self) -> None: self._run_action("start") @@ -142,3 +194,26 @@ class ServicesTab(Container): ) self.app.run_worker(run(), group="service-action", exclusive=False) + + def _update_action_status(self) -> None: + info = self._selected_service() + name = info.name if info else "—" + running = sum(1 for s in self._services if s.running) + self.status_bar.update( + f"[dim]{running}/{len(self._services)} running[/] │ " + f"selected: [bold]{name}[/] │ Start Stop Restart" + ) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + if event.data_table.id == "services-table": + self._update_action_status() + + def on_button_pressed(self, event: Button.Pressed) -> None: + mapping = { + "btn-start": "start", + "btn-stop": "stop", + "btn-restart": "restart", + } + action = mapping.get(event.button.id or "") + if action: + self._run_action(action) diff --git a/pyproject.toml b/pyproject.toml index dc89365..96c5358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsmview" -version = "0.3.0" +version = "0.3.1" description = "TUI SSH monitor for Synology DSM 7.x" readme = "README.md" license = { text = "MIT" }