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 start
S stop S stop
R restart R restart
(or use the Start / Stop / Restart buttons)
``` ```
## Requirements ## 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: def action_switch_tab(self, tab_id: str) -> None:
self.query_one(TabbedContent).active = tab_id self.query_one(TabbedContent).active = tab_id
if tab_id == "services":
self.services_tab.focus_table()
def action_filter(self, name: str) -> None: def action_filter(self, name: str) -> None:
self.query_one(TabbedContent).active = "logs" self.query_one(TabbedContent).active = "logs"

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from textual.app import ComposeResult 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.screen import ModalScreen
from textual.widgets import Button, DataTable, Static from textual.widgets import Button, DataTable, Static
@ -54,28 +54,63 @@ class ConfirmActionScreen(ModalScreen[bool]):
self.dismiss(event.button.id == "confirm-yes") self.dismiss(event.button.id == "confirm-yes")
class ServicesTab(Container): class ServicesTab(VerticalScroll):
"""Interactive service list with start/stop/restart actions.""" """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: def __init__(self, executor: Executor) -> None:
super().__init__() super().__init__()
self.executor = executor self.executor = executor
self._services: list[ServiceInfo] = [] self._services: list[ServiceInfo] = []
self._last_cursor_row: int = 0
self._last_selected_unit: str = ""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(classes="panel"): with Container(classes="panel"):
yield Static( yield Static(
"SERVICES — ↑/↓ select [s]tart [S]top [R]estart", "SERVICES — ↑/↓ select, then Start / Stop / Restart",
classes="panel-title", classes="panel-title",
id="services-header", id="services-header",
) )
self.status_bar = Static("loading...", id="services-status") self.status_bar = Static("loading...", id="services-status")
yield self.status_bar 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") self.table.add_columns("SERVICE", "STATUS", "DESCRIPTION")
yield self.table 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: 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._services = services
self.table.clear() self.table.clear()
running = sum(1 for s in services if s.running) 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) 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: def action_start_service(self) -> None:
self._run_action("start") self._run_action("start")
@ -142,3 +194,26 @@ class ServicesTab(Container):
) )
self.app.run_worker(run(), group="service-action", exclusive=False) 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] [project]
name = "dsmview" name = "dsmview"
version = "0.3.0" version = "0.3.1"
description = "TUI SSH monitor for Synology DSM 7.x" description = "TUI SSH monitor for Synology DSM 7.x"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }