@@ -95,17 +95,18 @@ def gh_api(endpoint):
9595
9696
9797def get_head_info (repo ):
98- """Get HEAD SHA and commit date. Returns (sha, date_str) or (None, None)."""
98+ """Get HEAD SHA, commit date, and branch. Returns (sha, date_str, branch) or (None, None, None)."""
99+ branch = "main"
99100 data = gh_api (f"repos/{ repo } /commits/main" )
100101 if not data or "sha" not in data :
102+ branch = "master"
101103 data = gh_api (f"repos/{ repo } /commits/master" )
102104 if data and "sha" in data :
103105 sha = data ["sha" ]
104106 date_raw = data .get ("commit" , {}).get ("committer" , {}).get ("date" , "" )
105- # "2026-04-15T12:37:52Z" -> "2026-04-15 12:37"
106107 date_str = date_raw .replace ("T" , " " )[:16 ] if date_raw else ""
107- return sha , date_str
108- return None , None
108+ return sha , date_str , branch
109+ return None , None , None
109110
110111
111112def get_ci_runs (repo ):
@@ -263,15 +264,17 @@ def sub_status_text(pinned, latest):
263264
264265COL_OWNER = 12
265266COL_REPO = 24
267+ COL_BRANCH = 9
266268COL_COMMIT = 9
267269COL_DATE = 18
268270
269271
270- def repo_label (owner , name , head = None , date = None , ci_ok = None , subs_ok = None ):
272+ def repo_label (owner , name , branch = None , head = None , date = None , ci_ok = None , subs_ok = None ):
271273 """Build a rich Text label for a repo tree node with fixed-width columns."""
272274 t = Text ()
273275 t .append (f"{ owner :<{COL_OWNER }s} " , style = "dim" )
274276 t .append (f"{ name :<{COL_REPO }s} " , style = "bold" )
277+ t .append (f"{ branch or '…' :<{COL_BRANCH }s} " , style = "dim" )
275278 t .append (f"{ head [:7 ] if head else '…' :<{COL_COMMIT }s} " , style = "dim" )
276279 t .append (f"{ date or '…' :<{COL_DATE }s} " , style = "dim" )
277280 if ci_ok is None and subs_ok is None :
@@ -285,21 +288,23 @@ def repo_label(owner, name, head=None, date=None, ci_ok=None, subs_ok=None):
285288 return t
286289
287290
288- SORT_COLUMNS = ["owner" , "repo" , "commit" , "updated" , "status" ]
289- COL_HEADER_PAD = 4 # left padding on #col- header
291+ SORT_COLUMNS = ["owner" , "repo" , "branch" , " commit" , "updated" , "status" ]
292+ COL_HEADER_PAD = 7 # tree root indent + padding in header label
290293
291294
292295def col_header_text (sort_key = "owner" , sort_ascending = True ):
293296 """Build the column header row with sort indicator."""
294297 arrow = " ▲" if sort_ascending else " ▼"
298+ # Pad to align with child nodes (tree guide adds ~5 chars for children vs root)
299+ t = Text (" " )
295300 cols = [
296301 ("owner" , COL_OWNER ),
297302 ("repo" , COL_REPO ),
303+ ("branch" , COL_BRANCH ),
298304 ("commit" , COL_COMMIT ),
299305 ("updated" , COL_DATE ),
300306 ("status" , 0 ),
301307 ]
302- t = Text ()
303308 for name , width in cols :
304309 label = name + (arrow if name == sort_key else "" )
305310 if width :
@@ -312,7 +317,7 @@ def col_header_text(sort_key="owner", sort_ascending=True):
312317def col_from_click_x (x ):
313318 """Determine which column was clicked based on x offset."""
314319 x -= COL_HEADER_PAD
315- boundaries = [COL_OWNER , COL_REPO , COL_COMMIT , COL_DATE ]
320+ boundaries = [COL_OWNER , COL_REPO , COL_BRANCH , COL_COMMIT , COL_DATE ]
316321 pos = 0
317322 for i , w in enumerate (boundaries ):
318323 pos += w
@@ -381,7 +386,9 @@ class RepoTree(Tree):
381386 event .prevent_default ()
382387 event .stop ()
383388 elif event .key == "left" :
384- if node .is_expanded :
389+ if node == self .root :
390+ pass # don't collapse root (header row)
391+ elif node .is_expanded :
385392 node .collapse ()
386393 elif node .parent and node .parent != self .root :
387394 self .select_node (node .parent )
@@ -405,12 +412,11 @@ class RepoStatusApp(App):
405412 background: $surface;
406413 }
407414 #col-header {
408- padding: 0 2 0 4;
409- color: $text 60%;
410- height: auto;
415+ display: none;
411416 }
412417 Tree {
413418 padding: 0 2;
419+ overflow-x: auto;
414420 }
415421 Tree > .tree--cursor {
416422 background: $accent 30%;
@@ -431,6 +437,8 @@ class RepoStatusApp(App):
431437 Binding ("ctrl+right" , "expand_level" , "Level +" , show = False ),
432438 Binding ("ctrl+left" , "collapse_level" , "Level -" , show = False ),
433439 Binding ("s" , "toggle_submodules" , "Toggle subs" ),
440+ Binding ("alt+right" , "scroll_right_fast" , "Scroll right" , show = False ),
441+ Binding ("alt+left" , "scroll_left_fast" , "Scroll left" , show = False ),
434442 ]
435443
436444 async def action_quit (self ) -> None :
@@ -444,8 +452,9 @@ class RepoStatusApp(App):
444452 def compose (self ) -> ComposeResult :
445453 yield Header ()
446454 yield Static (col_header_text (), id = "col-header" )
447- tree : RepoTree = RepoTree ("Repos" , id = "repo-tree" )
448- tree .show_root = False
455+ tree : RepoTree = RepoTree (col_header_text (), id = "repo-tree" )
456+ tree .show_root = True
457+ tree .root .expand ()
449458 tree .guide_depth = 3
450459 yield tree
451460 yield Static ("" , id = "level-indicator" )
@@ -534,6 +543,9 @@ class RepoStatusApp(App):
534543 sub .collapse ()
535544 else : # level 3
536545 node .expand_all ()
546+ # Keep root (header row) always expanded
547+ tree = self .query_one ("#repo-tree" , RepoTree )
548+ tree .root .expand ()
537549 self ._update_level_indicator ()
538550
539551 def action_toggle_submodules (self ) -> None :
@@ -554,26 +566,41 @@ class RepoStatusApp(App):
554566 sub_node .parent .expand ()
555567 sub_node .expand_all ()
556568
569+ def action_scroll_right_fast (self ) -> None :
570+ tree = self .query_one ("#repo-tree" , RepoTree )
571+ tree .scroll_relative (x = 10 )
572+
573+ def action_scroll_left_fast (self ) -> None :
574+ tree = self .query_one ("#repo-tree" , RepoTree )
575+ tree .scroll_relative (x = - 10 )
576+
557577 def on_click (self , event ) -> None :
558- """Handle clicks on the column header for sorting."""
578+ """Handle clicks on the column header (tree root) for sorting."""
579+ tree = self .query_one ("#repo-tree" , RepoTree )
580+ # Check if click is on the root node (header row) by checking y position
581+ # The root node is at y=0 in the tree's content area
559582 widget = event .widget
560- # Walk up to find if click was on #col-header
561583 while widget is not None :
562- if hasattr (widget , "id" ) and widget .id == "col-header" :
563- col = col_from_click_x (event .x )
564- if col == self ._sort_key :
565- self ._sort_ascending = not self ._sort_ascending
566- else :
567- self ._sort_key = col
568- self ._sort_ascending = True
569- self ._update_col_header ()
570- self ._rebuild_tree ()
584+ if hasattr (widget , "id" ) and widget .id == "repo-tree" :
585+ # Determine if click was on the root/header line
586+ _ , scroll_y = tree .scroll_offset
587+ click_line = event .y + scroll_y
588+ if click_line == 0 : # root node is line 0
589+ col = col_from_click_x (event .x )
590+ if col == self ._sort_key :
591+ self ._sort_ascending = not self ._sort_ascending
592+ else :
593+ self ._sort_key = col
594+ self ._sort_ascending = True
595+ self ._update_col_header ()
596+ self ._rebuild_tree ()
571597 return
572598 widget = getattr (widget , "parent" , None )
573599
574600 def _update_col_header (self ) -> None :
575- header = self .query_one ("#col-header" , Static )
576- header .update (col_header_text (self ._sort_key , self ._sort_ascending ))
601+ label = col_header_text (self ._sort_key , self ._sort_ascending )
602+ tree = self .query_one ("#repo-tree" , RepoTree )
603+ tree .root .set_label (label )
577604
578605 def _sort_value (self , repo , key ):
579606 """Extract a sortable value for a repo by column key."""
@@ -583,6 +610,8 @@ class RepoStatusApp(App):
583610 return owner .lower ()
584611 elif key == "repo" :
585612 return name .lower ()
613+ elif key == "branch" :
614+ return data .get ("branch" ) or ""
586615 elif key == "commit" :
587616 return data .get ("sha" ) or ""
588617 elif key == "updated" :
@@ -619,13 +648,14 @@ class RepoStatusApp(App):
619648 data = self ._repo_data .get (repo , {})
620649 sha = data .get ("sha" )
621650 date = data .get ("date" )
651+ branch = data .get ("branch" )
622652 ci_runs = data .get ("ci_runs" )
623653 submodules = data .get ("submodules" )
624654 sha_cache = data .get ("sha_cache" , {})
625655 ci_ok_val = data .get ("ci_ok" )
626656 subs_ok_val = data .get ("subs_ok" )
627657
628- label = repo_label (owner , name , sha , date , ci_ok_val , subs_ok_val )
658+ label = repo_label (owner , name , branch , sha , date , ci_ok_val , subs_ok_val )
629659 node = tree .root .add (label , data = {"repo" : repo }, expand = False )
630660 self ._repo_nodes [repo ] = node
631661
@@ -671,7 +701,7 @@ class RepoStatusApp(App):
671701
672702 # Phase 1: HEAD + CI (parallel) — update labels as they arrive
673703 update_status (f"Fetching HEAD + CI… (0/{ total * 2 } )" )
674- head_cache = {} # repo -> (sha, date_str)
704+ head_cache = {} # repo -> (sha, date_str, branch )
675705 ci_cache = {}
676706 ready = set ()
677707
@@ -690,20 +720,21 @@ class RepoStatusApp(App):
690720 return
691721 kind , repo = all_futures [future ]
692722 if kind == "head" :
693- head_cache [repo ] = future .result () # (sha, date)
723+ head_cache [repo ] = future .result () # (sha, date, branch )
694724 else :
695725 ci_cache [repo ] = future .result ()
696726 done += 1
697727 update_status (f"Fetching HEAD + CI… ({ done } /{ total * 2 } )" )
698728
699729 if repo not in ready and repo in head_cache and repo in ci_cache :
700730 ready .add (repo )
701- sha , date = head_cache [repo ]
731+ sha , date , branch = head_cache [repo ]
702732 self .call_from_thread (
703733 self ._update_repo_label ,
704734 repo ,
705735 sha ,
706736 date ,
737+ branch ,
707738 ci_cache .get (repo , []),
708739 )
709740
@@ -758,7 +789,7 @@ class RepoStatusApp(App):
758789 pool .shutdown (wait = False , cancel_futures = True )
759790 return
760791 ext_repo = futures [future ]
761- sha , date = future .result ()
792+ sha , date , branch = future .result ()
762793 if sha :
763794 sha_cache [ext_repo ] = sha
764795 done += 1
@@ -813,23 +844,23 @@ class RepoStatusApp(App):
813844 text = f"{ frame } { text } "
814845 self .sub_title = text
815846
816- def _update_repo_label (self , repo , head , date , ci_runs ) -> None :
817- """Update just the label (HEAD + CI + date), submodules still unknown."""
847+ def _update_repo_label (self , repo , head , date , branch , ci_runs ) -> None :
848+ """Update just the label (HEAD + CI + date + branch ), submodules still unknown."""
818849 node = self ._repo_nodes [repo ]
819850 owner , name = repo .split ("/" )
820851 ci_ok_val = ci_ok (ci_runs )
821- node .set_label (repo_label (owner , name , head , date , ci_ok_val ))
852+ node .set_label (repo_label (owner , name , branch , head , date , ci_ok_val ))
822853 # Cache for sorting
823854 self ._repo_data .setdefault (repo , {}).update (
824- sha = head , date = date , ci_runs = ci_runs , ci_ok = ci_ok_val
855+ sha = head , date = date , branch = branch , ci_runs = ci_runs , ci_ok = ci_ok_val
825856 )
826857
827858 def _update_repo_node (
828859 self , repo , sha_cache , head_cache , ci_runs , submodules
829860 ) -> None :
830861 node = self ._repo_nodes [repo ]
831862 owner , name = repo .split ("/" )
832- head_sha , head_date = head_cache .get (repo , (None , None ))
863+ head_sha , head_date , head_branch = head_cache .get (repo , (None , None , None ))
833864
834865 # Compute submodule staleness
835866 stale = 0
@@ -844,13 +875,14 @@ class RepoStatusApp(App):
844875 subs_ok_val = (stale == 0 ) if submodules else None
845876 ci_ok_val = ci_ok (ci_runs )
846877
847- node .set_label (repo_label (owner , name , head_sha , head_date , ci_ok_val , subs_ok_val ))
878+ node .set_label (repo_label (owner , name , head_branch , head_sha , head_date , ci_ok_val , subs_ok_val ))
848879 node .remove_children ()
849880
850881 # Cache for sorting
851882 self ._repo_data [repo ] = {
852883 "sha" : head_sha ,
853884 "date" : head_date ,
885+ "branch" : head_branch ,
854886 "ci_runs" : ci_runs ,
855887 "ci_ok" : ci_ok_val ,
856888 "submodules" : submodules ,
0 commit comments