@@ -25,6 +25,9 @@ class UpdatePage(QtWidgets.QWidget):
2525 request_status : typing .ClassVar [QtCore .pyqtSignal ] = QtCore .pyqtSignal (
2626 name = "request-status"
2727 )
28+ request_cancel : typing .ClassVar [QtCore .pyqtSignal ] = QtCore .pyqtSignal (
29+ name = "request-cancel"
30+ )
2831 update_available : typing .ClassVar [QtCore .pyqtSignal ] = QtCore .pyqtSignal (
2932 bool , name = "update-available"
3033 )
@@ -63,17 +66,44 @@ def __init__(self) -> None:
6366 self ._busy : bool = False
6467 self ._update_avail : bool = False
6568 self ._post_update_status_pending : bool = False
69+ self ._elapsed_time_seconds : int = 0
70+ self ._elapsed_timer : QtCore .QTimer = QtCore .QTimer (self )
71+ self ._elapsed_timer .setSingleShot (False )
72+ self ._elapsed_timer .setInterval (1000 )
73+ self ._elapsed_timer .timeout .connect (self ._update_elapsed_time )
6674 self .show_loading (True )
6775
6876 def _request_status_debounced (self ) -> None :
6977 self ._status_debounce .start (500 )
7078
79+ def _update_elapsed_time (self ) -> None :
80+ """Update and display elapsed time counter."""
81+ self ._elapsed_time_seconds += 1
82+ minutes = self ._elapsed_time_seconds // 60
83+ seconds = self ._elapsed_time_seconds % 60
84+ self ._elapsed_time_label .setText (f"{ minutes :02d} :{ seconds :02d} " )
85+
7186 def showEvent (self , a0 : QtGui .QShowEvent | None ) -> None :
7287 self .build_cards ()
7388 self ._post_update_status_pending = True
7489 self ._request_status_debounced ()
7590 return super ().showEvent (a0 )
7691
92+ def resizeEvent (self , a0 : QtGui .QResizeEvent | None ) -> None :
93+ """Position elapsed time label and cancel button within the load widget area."""
94+ if self ._loadwidget .isVisible ():
95+ load_widget_height = self ._loadwidget .height ()
96+ load_widget_width = self ._loadwidget .width ()
97+ # Position elapsed time label below the spinner (around 70% down)
98+ elapsed_y = int (load_widget_height * 0.70 )
99+ elapsed_x = (load_widget_width - 100 ) // 2
100+ self ._elapsed_time_label .setGeometry (elapsed_x , elapsed_y , 100 , 40 )
101+ # Position cancel button below the elapsed time label
102+ cancel_y = int (load_widget_height * 0.80 )
103+ cancel_x = (load_widget_width - 240 ) // 2
104+ self ._cancel_btn .setGeometry (cancel_x , cancel_y , 240 , 50 )
105+ return super ().resizeEvent (a0 )
106+
77107 def _needs_update (self , status : ComponentStatus ) -> bool :
78108 return bool (
79109 status .commits_behind
@@ -253,7 +283,15 @@ def handle_busy_changed(self, busy: bool) -> None:
253283 _log .info ("handle_busy_changed: %s" , busy )
254284 self ._busy = busy
255285 self .show_loading (busy )
256- if not busy :
286+ if busy :
287+ self ._elapsed_time_seconds = 0
288+ self ._elapsed_timer .start ()
289+ self ._elapsed_time_label .show ()
290+ self ._cancel_btn .show ()
291+ else :
292+ self ._elapsed_timer .stop ()
293+ self ._elapsed_time_label .hide ()
294+ self ._cancel_btn .hide ()
257295 self ._post_update_status_pending = True
258296 self ._request_status_debounced ()
259297 # Don't dismiss the overlay yet — wait for status_ready to refresh
@@ -263,6 +301,12 @@ def handle_busy_changed(self, busy: bool) -> None:
263301 def on_update_all_clicked (self ) -> None :
264302 self ._do_update ()
265303
304+ @QtCore .pyqtSlot (name = "on-cancel-clicked" )
305+ def _on_cancel_clicked (self ) -> None :
306+ """Emit cancel signal when user clicks cancel button."""
307+ _log .info ("Cancel button clicked" )
308+ self .request_cancel .emit ()
309+
266310 @QtCore .pyqtSlot (name = "do-update" )
267311 def _do_update (self ) -> None :
268312 self .request_update .emit ("" )
@@ -280,6 +324,30 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None:
280324 _log .info ("step_complete: %s %d/%d (%s)" , name , step , total , label )
281325 self .call_load_panel .emit (True , f"{ name } : { label } ({ step } /{ total } )" )
282326
327+ def _show_toast (self , message : str , * , success : bool = False ) -> None :
328+ color = "#4caf50" if success else "#ef5350"
329+ self ._toast .setStyleSheet (
330+ f"background: { color } ; color: #fff; padding: 4px 12px; border-radius: 8px;"
331+ )
332+ self ._toast .setText (message )
333+ self ._toast .show ()
334+ self ._toast .raise_ ()
335+ self ._toast_timer .start ()
336+
337+ def handle_error_occurred (self , name : str , reason : str ) -> None :
338+ self ._show_toast (f"{ name } : { reason } " )
339+
340+ def handle_rollback_done (self , name : str , success : bool ) -> None :
341+ self ._show_toast (
342+ f"{ name } : { 'rolled back' if success else 'rollback failed' } " ,
343+ success = success ,
344+ )
345+
346+ def handle_recover_done (self , name : str , success : bool ) -> None :
347+ self ._show_toast (
348+ f"{ name } : recovery { 'complete' if success else 'failed' } " , success = success
349+ )
350+
283351 def show_loading (self , loading : bool = False ) -> None :
284352 self .setUpdatesEnabled (False )
285353 self ._loadwidget .setVisible (loading )
@@ -373,12 +441,31 @@ def _setupUI(self) -> None:
373441 self ._scroll_area .setWidget (self ._scroll_content )
374442 content .addWidget (self ._scroll_area , 1 )
375443
376- # Loading overlay
444+ # Loading overlay with elapsed time and cancel button
377445 self ._loadwidget = LoadingOverlayWidget (
378446 self , LoadingOverlayWidget .AnimationGIF .DEFAULT
379447 )
380448 content .addWidget (self ._loadwidget , 1 )
381449
450+ # Elapsed time label (positioned in the load widget area via custom overlay)
451+ self ._elapsed_time_label = QtWidgets .QLabel ("00:00" , self ._loadwidget )
452+ self ._elapsed_time_label .setStyleSheet (
453+ "color: rgba(255, 255, 255, 200); background: transparent;"
454+ )
455+ self ._elapsed_time_label .setFont (QtGui .QFont (self ._font_family , 16 ))
456+ self ._elapsed_time_label .setAlignment (QtCore .Qt .AlignmentFlag .AlignCenter )
457+ self ._elapsed_time_label .setFixedWidth (100 )
458+ self ._elapsed_time_label .hide ()
459+
460+ # Cancel button (positioned in the load widget area)
461+ self ._cancel_btn = BlocksCustomButton (self ._loadwidget )
462+ self ._cancel_btn .setMinimumSize (QtCore .QSize (180 , 50 ))
463+ self ._cancel_btn .setMaximumSize (QtCore .QSize (240 , 50 ))
464+ self ._cancel_btn .setFont (QtGui .QFont (self ._font_family , 18 ))
465+ self ._cancel_btn .setText ("Cancel" )
466+ self ._cancel_btn .clicked .connect (self ._on_cancel_clicked )
467+ self ._cancel_btn .hide ()
468+
382469 # Update All button
383470 self .update_all_btn = BlocksCustomButton (self )
384471 self .update_all_btn .setMinimumSize (QtCore .QSize (240 , 70 ))
@@ -391,6 +478,18 @@ def _setupUI(self) -> None:
391478 content .addWidget (self .update_all_btn , 0 , QtCore .Qt .AlignmentFlag .AlignCenter )
392479 self .update_all_btn .hide ()
393480
481+ self ._toast = QtWidgets .QLabel (self )
482+ self ._toast .setWordWrap (True )
483+ self ._toast .setAlignment (QtCore .Qt .AlignmentFlag .AlignCenter )
484+ self ._toast .setFont (QtGui .QFont (self ._font_family , 13 ))
485+ self ._toast .setFixedHeight (46 )
486+ self ._toast .hide ()
487+ content .addWidget (self ._toast , 0 )
488+ self ._toast_timer = QtCore .QTimer (self )
489+ self ._toast_timer .setSingleShot (True )
490+ self ._toast_timer .setInterval (8000 )
491+ self ._toast_timer .timeout .connect (self ._toast .hide )
492+
394493 _arrow = QtGui .QPixmap (":/arrow_icons/media/btn_icons/arrow_right.svg" )
395494 self ._chevron_right : QtGui .QPixmap = _arrow
396495 self ._chevron_down : QtGui .QPixmap = _arrow .transformed (
0 commit comments