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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 03:19:57 +02:00
parent 0e02ba6876
commit f9981831fa
7 changed files with 34 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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