@@ -68,12 +68,43 @@ def _dur(state: Optional[dict]) -> str:
6868 return ""
6969
7070
71+ def _row_status (r : PkgSyncResult ):
72+ """Return (delta_Text, status_Text) for a completed result row."""
73+ from rich .text import Text
74+ if r .outcome == SyncOutcome .SYNCED :
75+ status = Text ("%s→%s" % (r .old_commit or "?" , r .new_commit or "?" ), style = "green" )
76+ delta = Text ("↓%d" % r .commits_behind , style = "green" ) if r .commits_behind else Text ("" )
77+ elif r .outcome == SyncOutcome .UP_TO_DATE :
78+ status = Text ("up-to-date %s" % (r .old_commit or "" ), style = "dim" )
79+ delta = Text ("=" , style = "dim" )
80+ elif r .outcome == SyncOutcome .CONFLICT :
81+ status = Text ("conflict %s" % (r .old_commit or "" ), style = "bold red" )
82+ delta = Text ("↓%d" % r .commits_behind , style = "red" ) if r .commits_behind else Text ("" )
83+ elif r .outcome == SyncOutcome .DIRTY :
84+ status = Text ("dirty %s" % (r .old_commit or "" ), style = "bold yellow" )
85+ delta = Text ("" )
86+ elif r .outcome == SyncOutcome .AHEAD :
87+ ahead_str = "↑%d" % r .commits_ahead if r .commits_ahead else "ahead"
88+ status = Text ("ahead %s" % (r .old_commit or "" ), style = "bold yellow" )
89+ delta = Text (ahead_str , style = "bold yellow" )
90+ elif r .outcome == SyncOutcome .ERROR :
91+ status = Text (r .error or "error" , style = "bold red" )
92+ delta = Text ("" )
93+ elif r .outcome in _DRY_OUTCOMES :
94+ status = Text ("%s %s" % (r .outcome .value , r .old_commit or "" ), style = "cyan" )
95+ delta = Text ("↓%d" % r .commits_behind , style = "cyan" ) if r .commits_behind else Text ("" )
96+ else : # SKIPPED
97+ status = Text (r .skipped_reason or "skipped" , style = "dim" )
98+ delta = Text ("" )
99+ return delta , status
100+
101+
71102# ---------------------------------------------------------------------------
72103# Rich TUI
73104# ---------------------------------------------------------------------------
74105
75106class RichSyncTUI (SyncProgressListener ):
76- """Rich-based TUI: live spinner display during sync, final table after ."""
107+ """Rich-based TUI: single live table that becomes the final output ."""
77108
78109 def __init__ (self ):
79110 from rich .console import Console
@@ -100,135 +131,81 @@ def on_pkg_result(self, result: PkgSyncResult) -> None:
100131 # ── Lifecycle ─────────────────────────────────────────────────────────
101132
102133 def start (self ):
103- """Begin the live display (call before triggering sync)."""
104134 from rich .live import Live
105135 from rich .table import Table
106- tbl = Table (show_header = False , box = None , padding = (0 , 1 ))
136+ tbl = Table (show_header = True , header_style = "bold" , box = None , padding = (0 , 1 ))
107137 self ._live = Live (tbl , console = self .console , refresh_per_second = 10 )
108138 self ._live .start ()
109139
110140 def stop (self ):
111- """End the live display."""
112141 if self ._live :
113142 self ._live .stop ()
114143 self ._live = None
115144
116- def _refresh (self ):
117- if not self ._live :
118- return
145+ def _build_table (self , spinner = True ):
146+ """Build the unified package table (used for both live and final display)."""
119147 from rich .spinner import Spinner
120148 from rich .table import Table
121149 from rich .text import Text
122150
123- tbl = Table (show_header = False , box = None , padding = (0 , 1 ))
124- tbl .add_column ("s" , width = 3 )
125- tbl .add_column ("name" , style = "bold" )
126- tbl .add_column ("info" )
151+ tbl = Table (show_header = True , header_style = "bold" , box = None , padding = (0 , 1 ))
152+ tbl .add_column ("" , width = 3 , no_wrap = True )
153+ tbl .add_column ("Package" , style = "bold" , no_wrap = True )
154+ tbl .add_column ("Branch" , no_wrap = True )
155+ tbl .add_column ("Status" , no_wrap = True )
156+ tbl .add_column ("Δ" , no_wrap = True )
157+ tbl .add_column ("Time" , no_wrap = True )
127158
128159 for name in self ._order :
129160 state = self ._pkg_states [name ]
130161 if not state ["done" ]:
131- marker = Spinner ("dots" , style = "bold cyan" )
132- info = Text (name , style = "dim" )
162+ if spinner :
163+ marker = Spinner ("dots" , style = "bold cyan" )
164+ else :
165+ marker = Text ("…" , style = "dim" )
166+ tbl .add_row (marker , Text (name ), Text ("" ), Text ("" , style = "dim" ),
167+ Text ("" ), Text ("" ))
133168 else :
134169 r = state ["result" ]
170+ # Skip pypi in the live/final table
171+ if r .src_type == "pypi" :
172+ continue
135173 icon , style = _ICONS .get (r .outcome , ("?" , "dim" ))
136- marker = Text (icon , style = style )
137- dur = "%.1fs" % state .get ("duration" , 0 )
138- label = r .outcome .value
139- if r .outcome == SyncOutcome .SYNCED and r .old_commit and r .new_commit :
140- label = "%s→%s" % (r .old_commit , r .new_commit )
141- elif r .outcome == SyncOutcome .SKIPPED :
142- label = r .skipped_reason or "skipped"
143- elif r .outcome == SyncOutcome .ERROR :
144- label = r .error or "error"
145- info = Text ("%s %s" % (label , dur ))
174+ marker = Text (icon , style = style )
175+ branch = Text (r .branch or "—" , style = "" if r .branch else "dim" )
176+ dur = Text (_dur (state ), style = "dim" )
177+ delta , status = _row_status (r )
178+ tbl .add_row (marker , Text (name ), branch , status , delta , dur )
146179
147- tbl . add_row ( marker , Text ( name ), info )
180+ return tbl
148181
149- self ._live .update (tbl )
182+ def _refresh (self ):
183+ if self ._live :
184+ self ._live .update (self ._build_table (spinner = True ))
150185
151186 # ── Final render ──────────────────────────────────────────────────────
152187
153188 def render (self , results : List [PkgSyncResult ], dry_run : bool = False ):
154- from rich .console import Console
155- from rich .table import Table
156189 from rich .text import Text
157190 from rich .panel import Panel
158191
159- console = Console ()
160-
161- # ── Main table — one row per package with inline status + duration ─
162- table = Table (show_header = True , header_style = "bold" , box = None ,
163- padding = (0 , 1 ))
164- table .add_column ("" , width = 3 , no_wrap = True )
165- table .add_column ("Package" , style = "bold" , no_wrap = True )
166- table .add_column ("Branch" , no_wrap = True )
167- table .add_column ("Status" , no_wrap = True )
168- table .add_column ("Δ" , no_wrap = True )
169- table .add_column ("Time" , no_wrap = True )
192+ # Final refresh with complete data, then stop the live display.
193+ if self ._live :
194+ self ._live .update (self ._build_table (spinner = False ))
195+ self ._live .stop ()
196+ self ._live = None
170197
171198 counts = {o : 0 for o in SyncOutcome }
172199 pypi_count = 0
173200 attention_items = []
174-
175201 for r in results :
176202 counts [r .outcome ] += 1
177-
178- # Hide pypi packages (sync is a no-op for them); tally for summary.
179203 if r .src_type == "pypi" :
180204 pypi_count += 1
181205 continue
182-
183- icon , style = _ICONS .get (r .outcome , ("?" , "dim" ))
184- marker = Text (icon , style = style )
185- branch_text = Text (r .branch or "—" , style = "" if r .branch else "dim" )
186- dur_text = Text (_dur (self ._pkg_states .get (r .name )), style = "dim" )
187-
188- if r .outcome == SyncOutcome .SYNCED :
189- status = Text ("%s→%s" % (r .old_commit or "?" , r .new_commit or "?" ),
190- style = "green" )
191- delta = Text ("↓%d" % r .commits_behind , style = "green" ) if r .commits_behind else Text ("" )
192-
193- elif r .outcome == SyncOutcome .UP_TO_DATE :
194- status = Text ("up-to-date %s" % (r .old_commit or "" ), style = "dim" )
195- delta = Text ("=" , style = "dim" )
196-
197- elif r .outcome == SyncOutcome .CONFLICT :
198- status = Text ("conflict %s" % (r .old_commit or "" ), style = "bold red" )
199- delta = Text ("↓%d" % r .commits_behind , style = "red" ) if r .commits_behind else Text ("" )
200- attention_items .append (r )
201-
202- elif r .outcome == SyncOutcome .DIRTY :
203- status = Text ("dirty %s" % (r .old_commit or "" ), style = "bold yellow" )
204- delta = Text ("" )
205- attention_items .append (r )
206-
207- elif r .outcome == SyncOutcome .AHEAD :
208- ahead_str = "↑%d" % r .commits_ahead if r .commits_ahead else "ahead"
209- status = Text ("ahead %s" % (r .old_commit or "" ), style = "bold yellow" )
210- delta = Text (ahead_str , style = "bold yellow" )
206+ if r .outcome in _ATTENTION_OUTCOMES :
211207 attention_items .append (r )
212208
213- elif r .outcome == SyncOutcome .ERROR :
214- status = Text (r .error or "error" , style = "bold red" )
215- delta = Text ("" )
216- attention_items .append (r )
217-
218- elif r .outcome in _DRY_OUTCOMES :
219- status = Text ("%s %s" % (r .outcome .value , r .old_commit or "" ), style = "cyan" )
220- delta = Text ("↓%d" % r .commits_behind , style = "cyan" ) if r .commits_behind else Text ("" )
221- if r .outcome in (SyncOutcome .DRY_DIRTY , SyncOutcome .DRY_WOULD_CONFLICT ):
222- attention_items .append (r )
223-
224- else : # SKIPPED
225- status = Text (r .skipped_reason or "skipped" , style = "dim" )
226- delta = Text ("" )
227-
228- table .add_row (marker , Text (r .name ), branch_text , status , delta , dur_text )
229-
230- console .print (table )
231-
232209 # ── Attention panel ───────────────────────────────────────────────
233210 if attention_items :
234211 lines = []
@@ -269,7 +246,7 @@ def render(self, results: List[PkgSyncResult], dry_run: bool = False):
269246 has_error = any (r .outcome in (SyncOutcome .CONFLICT , SyncOutcome .ERROR )
270247 for r in attention_items )
271248 border = "red" if has_error else "yellow"
272- console .print (Panel (
249+ self . console .print (Panel (
273250 "\n " .join (lines ).rstrip (),
274251 title = "Attention" , border_style = border ,
275252 ))
@@ -316,7 +293,7 @@ def render(self, results: List[PkgSyncResult], dry_run: bool = False):
316293 else :
317294 border = "green"
318295
319- console .print (Panel (summary , title = "Sync" , border_style = border ))
296+ self . console .print (Panel (summary , title = "Sync" , border_style = border ))
320297
321298
322299# ---------------------------------------------------------------------------
0 commit comments