fix(services): scrolling, cursor persistence and visible controls

- Make ServicesTab a VerticalScroll so the service list is scrollable.
- Preserve cursor position / selected unit across periodic refreshes.
- Add visible Start/Stop/Restart buttons.
- Show selected service in the status bar.
- Focus the service table when switching to F3.
- Bump version to 0.3.1.
This commit is contained in:
SW 2026-06-17 20:51:33 +02:00
parent 093960ffe4
commit 57d3c97ed4
5 changed files with 85 additions and 7 deletions

View file

@ -50,6 +50,7 @@ Services tab:
s start
S stop
R restart
(or use the Start / Stop / Restart buttons)
```
## Requirements

View file

@ -1 +1 @@
__version__ = "0.3.0"
__version__ = "0.3.1"

View file

@ -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"

View file

@ -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)

View file

@ -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" }