From f9981831fac571ed97f921ab285450ba892ee4bd Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 03:19:57 +0200 Subject: [PATCH] Add loading spinner to operation log during running tasks Shows an animated spinner next to the title while backup, restore, retention, remote test, and schedule operations are running. Spinner hides when the operation completes via finish(). Co-Authored-By: Claude Opus 4.6 --- tui/gniza.tcss | 13 ++++++++++++- tui/screens/backup.py | 2 ++ tui/screens/remotes.py | 1 + tui/screens/restore.py | 1 + tui/screens/retention.py | 2 ++ tui/screens/schedule.py | 3 +++ tui/widgets/operation_log.py | 16 +++++++++++++--- 7 files changed, 34 insertions(+), 4 deletions(-) diff --git a/tui/gniza.tcss b/tui/gniza.tcss index c511c2d..5a83b92 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -193,10 +193,21 @@ SelectionList { border: thick $accent; } +#ol-header { + height: auto; + margin: 0 0 1 0; +} + #ol-title { text-style: bold; color: #00cc00; - margin: 0 0 1 0; + width: 1fr; +} + +#ol-spinner { + width: auto; + min-width: 4; + height: 1; } #ol-log { diff --git a/tui/screens/backup.py b/tui/screens/backup.py index a927c0a..77f90ce 100644 --- a/tui/screens/backup.py +++ b/tui/screens/backup.py @@ -77,6 +77,7 @@ class BackupScreen(Screen): log_screen.write("\n[green]Backup completed successfully.[/green]") else: log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]") + log_screen.finish() @work async def _do_backup_all(self) -> None: @@ -87,6 +88,7 @@ class BackupScreen(Screen): log_screen.write("\n[green]All backups completed.[/green]") else: log_screen.write(f"\n[red]Backup failed (exit code {rc}).[/red]") + log_screen.finish() def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/screens/remotes.py b/tui/screens/remotes.py index e742b4e..2666c0b 100644 --- a/tui/screens/remotes.py +++ b/tui/screens/remotes.py @@ -94,6 +94,7 @@ class RemotesScreen(Screen): log_screen.write("\n[green]Connection test passed.[/green]") else: log_screen.write(f"\n[red]Connection test failed (exit code {rc}).[/red]") + log_screen.finish() def _delete_remote(self, name: str) -> None: conf = CONFIG_DIR / "remotes.d" / f"{name}.conf" diff --git a/tui/screens/restore.py b/tui/screens/restore.py index 9b186c7..25ef505 100644 --- a/tui/screens/restore.py +++ b/tui/screens/restore.py @@ -122,6 +122,7 @@ class RestoreScreen(Screen): log_screen.write("\n[green]Restore completed successfully.[/green]") else: log_screen.write(f"\n[red]Restore failed (exit code {rc}).[/red]") + log_screen.finish() def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/screens/retention.py b/tui/screens/retention.py index 0a99d96..25ee310 100644 --- a/tui/screens/retention.py +++ b/tui/screens/retention.py @@ -75,6 +75,7 @@ class RetentionScreen(Screen): log_screen.write("\n[green]Cleanup completed.[/green]") else: log_screen.write(f"\n[red]Cleanup failed (exit code {rc}).[/red]") + log_screen.finish() @work async def _do_cleanup_all(self) -> None: @@ -85,6 +86,7 @@ class RetentionScreen(Screen): log_screen.write("\n[green]All cleanups completed.[/green]") else: log_screen.write(f"\n[red]Cleanup failed (exit code {rc}).[/red]") + log_screen.finish() def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/screens/schedule.py b/tui/screens/schedule.py index db0a06d..88e722f 100644 --- a/tui/screens/schedule.py +++ b/tui/screens/schedule.py @@ -225,6 +225,7 @@ class ScheduleScreen(Screen): log_screen.write(stdout) if stderr: log_screen.write(stderr) + log_screen.finish() @work async def _remove_schedules(self) -> None: @@ -235,6 +236,7 @@ class ScheduleScreen(Screen): log_screen.write(stdout) if stderr: log_screen.write(stderr) + log_screen.finish() @work async def _show_crontab(self) -> None: @@ -245,6 +247,7 @@ class ScheduleScreen(Screen): log_screen.write(stdout) if stderr: log_screen.write(stderr) + log_screen.finish() def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/widgets/operation_log.py b/tui/widgets/operation_log.py index 1b1176a..e644087 100644 --- a/tui/widgets/operation_log.py +++ b/tui/widgets/operation_log.py @@ -3,8 +3,8 @@ import asyncio from rich.text import Text from textual.app import ComposeResult from textual.screen import ModalScreen -from textual.widgets import RichLog, Button, Static -from textual.containers import Vertical +from textual.widgets import RichLog, Button, Static, LoadingIndicator +from textual.containers import Vertical, Horizontal class OperationLog(ModalScreen[None]): @@ -16,10 +16,13 @@ class OperationLog(ModalScreen[None]): self._title = title self._mounted_event = asyncio.Event() self._buffer: list[str] = [] + self._running = True def compose(self) -> ComposeResult: with Vertical(id="op-log"): - yield Static(self._title, id="ol-title") + with Horizontal(id="ol-header"): + yield Static(self._title, id="ol-title") + yield LoadingIndicator(id="ol-spinner") yield RichLog(id="ol-log", wrap=True, highlight=True, markup=True) yield Button("Close", variant="primary", id="ol-close") @@ -43,6 +46,13 @@ class OperationLog(ModalScreen[None]): else: log.write(text) + def finish(self) -> None: + self._running = False + try: + self.query_one("#ol-spinner", LoadingIndicator).display = False + except Exception: + pass + def write(self, text: str) -> None: if not self._mounted_event.is_set(): self._buffer.append(text)