Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
From: Christian Brauner <brauner@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
	 "Christian Brauner (Amutable)" <brauner@kernel.org>
Subject: [PATCH RFC 10/11] review-tui: expand tracked series into per-version rows
Date: Sat, 18 Jul 2026 00:37:46 +0200	[thread overview]
Message-ID: <20260718-work-b4-multiver-rows-v1-10-3c539d2a3095@kernel.org> (raw)
In-Reply-To: <20260718-work-b4-multiver-rows-v1-0-3c539d2a3095@kernel.org>

Series with more than one known version become expandable: 'x'
unfolds indented child rows (one per catalog revision, the tracked
one starred) showing per-version unread badges and activity dates,
'X' toggles every series at once.  Enter or 'e' on a child opens that
revision's thread with revision-correct seen syncing, 'd' range-diffs
it against the tracked revision directly, and every other action
keeps operating on the parent series.  Expansion state and the
(change_id, revision) cursor position survive the periodic DB-mtime
reloads and limit filtering.

Assisted-by: LLM
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review_tui/_modals.py       |   7 +
 src/b4/review_tui/_tracking_app.py | 282 ++++++++++++++++++++++++++++++++++---
 2 files changed, 266 insertions(+), 23 deletions(-)

diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 5943426..32f92a3 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -328,9 +328,16 @@ TRACKING_HELP_LINES = [
     '  [bold]d[/bold]         Range-diff between revisions\n',
     '  [bold]a[/bold]         Open action menu (take, rebase, etc.)\n',
     '  [bold]u[/bold]         Update selected series\n',
+    '  [bold]x[/bold]         Expand/collapse the version rows of a ▸ series\n',
+    '\n',
+    '[bold]Version rows[/bold]\n',
+    '  v2*           Asterisk marks the revision the series tracks\n',
+    '  [bold]Enter[/bold] / [bold]e[/bold] View the thread of that version\n',
+    '  [bold]d[/bold]         Range-diff that version against the tracked one\n',
     '\n',
     '[bold]App[/bold]\n',
     '  [bold]U[/bold]         Update all tracked series\n',
+    '  [bold]X[/bold]         Expand/collapse all multi-version series\n',
     '  [bold]l[/bold]         Filter series by pattern\n',
     '  [bold]s[/bold]         Suspend to shell\n',
     '  [bold]p[/bold]         Switch to Patchwork TUI\n',
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 8800383..3135781 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -334,6 +334,27 @@ def _resolve_worktree_take_conflict(
     return True
 
 
+def _format_version(rev: Dict[str, Any], series: Dict[str, Any]) -> str:
+    """Summarize one known version of *series* for the details panel."""
+    revision = rev.get('revision', 1)
+    out = f'v{revision}'
+    if revision == series.get('revision', 1):
+        out += ' (tracked)'
+    count = rev.get('message_count')
+    if count is None:
+        out += ' — - msgs (- unseen)'
+    else:
+        unseen = max(0, int(count) - int(rev.get('seen_message_count') or 0))
+        out += f' — {count} msgs ({unseen} unseen)'
+    found_at = rev.get('found_at') or ''
+    if found_at:
+        out += f', found {found_at[:10]}'
+    last_activity = rev.get('last_activity_at') or ''
+    if last_activity:
+        out += f', last activity {last_activity[:10]}'
+    return out
+
+
 def _format_snooze_until(value: str) -> str:
     """Format a snoozed_until value for display.
 
@@ -576,9 +597,10 @@ class TrackedSeriesItem(ListItem):
     }
     """
 
-    def __init__(self, series: Dict[str, Any]) -> None:
+    def __init__(self, series: Dict[str, Any], expanded: bool = False) -> None:
         super().__init__()
         self.series = series
+        self.expanded = expanded
         status = series.get('status', 'new')
         effective = 'queued' if series.get('queued') else status
         if _STATUS_TIER.get(effective, 2) >= 2:
@@ -607,6 +629,11 @@ class TrackedSeriesItem(ListItem):
         width = len(str(num_patches)) if num_patches > 0 else 1
         parts = extras + [f'v{revision}', f'{"0" * width}/{num_patches:0{width}d}']
         subject_display = f'[{",".join(parts)}] {ls.subject}'
+        # Series with more than one known version can be expanded with [x]
+        if len(TrackingApp._merge_tracked_revision(self.series)) > 1:
+            # U+25BE black down-pointing / U+25B8 black right-pointing triangle
+            marker = '▾' if self.expanded else '▸'
+            subject_display = f'{marker} {subject_display}'
         if display_width(submitter) > 20:
             while display_width(submitter) > 19:
                 submitter = submitter[:-1]
@@ -633,6 +660,68 @@ class TrackedSeriesItem(ListItem):
         yield Label(label, markup=False)
 
 
+class TrackedRevisionItem(ListItem):
+    """A single known version of an expanded series.
+
+    Rendered as a child row underneath its TrackedSeriesItem, with the
+    submitter column replaced by a tree glyph and the version number.
+    """
+
+    DEFAULT_CSS = """
+    TrackedRevisionItem Label {
+        text-style: dim;
+    }
+    """
+
+    def __init__(
+        self,
+        series: Dict[str, Any],
+        rev: Dict[str, Any],
+        is_tracked: bool,
+        is_last: bool,
+    ) -> None:
+        super().__init__()
+        self.series = series
+        self.rev = rev
+        self.is_tracked = is_tracked
+        self.is_last = is_last
+
+    def compose(self) -> ComposeResult:
+        # U+2514/U+251C box drawings light up-and-right / vertical-and-right
+        tree = '└─' if self.is_last else '├─'
+        mark = '*' if self.is_tracked else ''
+        version = f'  {tree} v{self.rev.get("revision", 1)}{mark}'
+        # Date of the last known activity, falling back to when the
+        # revision was first seen.
+        date_str = ''
+        stamp = self.rev.get('last_activity_at') or self.rev.get('found_at') or ''
+        if stamp:
+            try:
+                date_str = datetime.datetime.fromisoformat(stamp).strftime('%d %b')
+            except (ValueError, TypeError):
+                date_str = ''
+        # Show each version's own title, prefix-stripped -- the version
+        # column already carries the vN part.  Fall back to the series
+        # subject when the catalog has none.
+        rev_subject = self.rev.get('subject') or self.series.get('subject') or ''
+        if rev_subject:
+            rev_subject = b4.LoreSubject(rev_subject).subject
+        label = RichText(no_wrap=True, overflow='ellipsis')
+        label.append(pad_display(version, 20))
+        # Attestation (1), separator (1) and A·R·T (7) stay blank
+        label.append(' ' * 9)
+        _append_msgs(
+            label,
+            self.app,
+            self.rev.get('message_count'),
+            self.rev.get('seen_message_count'),
+        )
+        label.append(f'  {date_str:<6s}')
+        if rev_subject:
+            label.append(f'  {rev_subject}')
+        yield Label(label, markup=False)
+
+
 class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
     """Textual app for browsing tracked series.
 
@@ -731,10 +820,12 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         'check': 'Series',
         'thread': 'Series',
         'range_diff': 'Series',
+        'toggle_expand': 'Series',
         'action': 'Series',
         'update_one': 'Series',
         'target_branch': 'Series',
         'update_all': 'App',
+        'expand_all': 'App',
         'process_queue': 'App',
         'limit': 'App',
         'suspend': 'App',
@@ -756,7 +847,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         Binding('a', 'action', 'action'),
         Binding('u', 'update_one', 'update'),
         Binding('d', 'range_diff', 'range-diff'),
+        Binding('x', 'toggle_expand', 'versions'),
         # App-global actions
+        Binding('X', 'expand_all', 'Expand all', key_display='X', show=False),
         Binding('l', 'limit', 'limit'),
         Binding('s', 'suspend', 'shell'),
         Binding('p', 'patchwork', 'patchwork'),
@@ -782,6 +875,11 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._patatt_sign = patatt_sign
         self._all_series: List[Dict[str, Any]] = []
         self._selected_series: Optional[Dict[str, Any]] = None
+        # Series showing per-version child rows, and the child row (if any)
+        # the cursor is on — both survive a list rebuild.
+        self._expanded_change_ids: set[str] = set()
+        self._selected_revision: Optional[Dict[str, Any]] = None
+        self._focus_revision: Optional[int] = None
         self._limit_pattern: str = ''
         self._db_mtime: float = 0.0
         # Detect patchwork configuration
@@ -829,6 +927,16 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._cached_revisions = None
         self._cached_art_counts = None
 
+    def _stash_focus(self) -> None:
+        """Remember the highlighted row so the next _refresh_list restores it."""
+        if self._selected_series:
+            self._focus_change_id = self._selected_series.get('change_id')
+        self._focus_revision = (
+            self._selected_revision.get('revision')
+            if self._selected_revision is not None
+            else None
+        )
+
     def _refresh_msg_count(self, series: Dict[str, Any], total_messages: int) -> None:
         """Opportunistically refresh message count after fetching messages."""
         if not self._identifier:
@@ -872,6 +980,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             with Horizontal(classes='details-row', id='detail-revisions-row'):
                 yield Static('Revisions:', classes='details-label')
                 yield Static('', id='detail-revisions', markup=False)
+            with Horizontal(classes='details-row', id='detail-version-row'):
+                yield Static('Version:', classes='details-label')
+                yield Static('', id='detail-version', markup=False)
             with Horizontal(classes='details-row', id='detail-branch-row'):
                 yield Static('Branch:', classes='details-label')
                 yield Static('', id='detail-branch', markup=False)
@@ -950,8 +1061,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 # pick up the change once the modal closes.
                 if len(self.app.screen_stack) > 1:
                     return
-                if self._selected_series:
-                    self._focus_change_id = self._selected_series.get('change_id')
+                self._stash_focus()
                 self._invalidate_caches()
                 self._load_series()
 
@@ -1141,8 +1251,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         except OSError:
             return
         if mtime != self._db_mtime:
-            if self._selected_series:
-                self._focus_change_id = self._selected_series.get('change_id')
+            self._stash_focus()
             self._invalidate_caches()
             self._load_series()
 
@@ -1216,7 +1325,26 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             header_text = f'{"Submitter":<20s}{"A":>1s} {"A·R·T":>7s}  {"Msgs":>6s}  {"S":<4s}{"Subject"}'
             header = Static(header_text, id='tracking-header')
 
-            list_items: List[ListItem] = [TrackedSeriesItem(s) for s in display_series]
+            list_items: List[ListItem] = []
+            for series in display_series:
+                revs = self._merge_tracked_revision(series)
+                expanded = (
+                    len(revs) > 1
+                    and series.get('change_id', '') in self._expanded_change_ids
+                )
+                list_items.append(TrackedSeriesItem(series, expanded=expanded))
+                if not expanded:
+                    continue
+                tracked = series.get('revision', 1)
+                for idx, rev in enumerate(revs):
+                    list_items.append(
+                        TrackedRevisionItem(
+                            series,
+                            rev,
+                            is_tracked=rev.get('revision') == tracked,
+                            is_last=idx == len(revs) - 1,
+                        )
+                    )
             lv = ListView(*list_items, id='tracking-list')
             await self.mount(header, before=self.query_one(Footer))
             await self.mount(lv, before=self.query_one(Footer))
@@ -1228,8 +1356,11 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     and item.series.get('change_id') == self._focus_change_id
                 ):
                     lv.index = idx
+                    if self._focus_revision is not None:
+                        lv.index = self._find_focus_child(list_items, idx)
                     break
             self._focus_change_id = None
+        self._focus_revision = None
         lv.focus()
 
         # Populate the details panel for the highlighted item now that
@@ -1239,10 +1370,30 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         # status without waiting for the async Highlighted message to
         # be processed from the queue.
         highlighted = lv.highlighted_child
-        if isinstance(highlighted, TrackedSeriesItem):
+        if isinstance(highlighted, TrackedRevisionItem):
+            self._selected_series = highlighted.series
+            self._selected_revision = highlighted.rev
+            self._show_details(highlighted.series, rev=highlighted.rev)
+        elif isinstance(highlighted, TrackedSeriesItem):
             self._selected_series = highlighted.series
+            self._selected_revision = None
             self._show_details(highlighted.series)
 
+    def _find_focus_child(self, list_items: List[ListItem], parent_idx: int) -> int:
+        """Index of the _focus_revision child row below *parent_idx*.
+
+        Falls back to the parent index when that version is no longer
+        listed — it may have gone away, or the series may have been
+        collapsed since the focus was stashed.
+        """
+        for idx in range(parent_idx + 1, len(list_items)):
+            item = list_items[idx]
+            if not isinstance(item, TrackedRevisionItem):
+                break
+            if item.rev.get('revision') == self._focus_revision:
+                return idx
+        return parent_idx
+
     def action_limit(self) -> None:
         self.push_screen(
             LimitScreen(
@@ -1255,8 +1406,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         if result is None:
             return
         self._limit_pattern = result
-        if self._selected_series:
-            self._focus_change_id = self._selected_series.get('change_id')
+        self._stash_focus()
         self._load_series()
 
     def action_cursor_down(self) -> None:
@@ -1271,17 +1421,58 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         except Exception:
             pass
 
+    def action_toggle_expand(self) -> None:
+        """Show or hide the version rows of the selected series."""
+        series = self._selected_series
+        if not series or len(self._merge_tracked_revision(series)) < 2:
+            return
+        change_id = series.get('change_id', '')
+        if change_id in self._expanded_change_ids:
+            self._expanded_change_ids.discard(change_id)
+            # The highlighted version row is about to go away
+            self._selected_revision = None
+        else:
+            self._expanded_change_ids.add(change_id)
+        self._stash_focus()
+        self.call_later(self._refresh_list)
+
+    def action_expand_all(self) -> None:
+        """Expand every multi-version series, or collapse them all."""
+        expandable = {
+            s.get('change_id', '')
+            for s in self._all_series
+            if len(self._merge_tracked_revision(s)) > 1
+        }
+        if not expandable:
+            return
+        if expandable - self._expanded_change_ids:
+            self._expanded_change_ids |= expandable
+        else:
+            self._expanded_change_ids.clear()
+            self._selected_revision = None
+        self._stash_focus()
+        self.call_later(self._refresh_list)
+
     def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
         if event.list_view.id != 'tracking-list':
             return
         item = event.item
-        if isinstance(item, TrackedSeriesItem):
+        if isinstance(item, TrackedRevisionItem):
+            self._selected_series = item.series
+            self._selected_revision = item.rev
+            self._show_details(item.series, rev=item.rev)
+        elif isinstance(item, TrackedSeriesItem):
             self._selected_series = item.series
+            self._selected_revision = None
             self._show_details(item.series)
         self.refresh_bindings()
 
     def on_list_view_selected(self, event: ListView.Selected) -> None:
         if event.list_view.id == 'tracking-list':
+            # A version row opens that version's thread
+            if isinstance(event.item, TrackedRevisionItem):
+                self.action_thread()
+                return
             if not self._selected_series:
                 return
             status = self._selected_series.get('status', 'new')
@@ -1355,6 +1546,11 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             return bool(self._pwkey and self._pwurl and self._pwproj)
         if action == 'action':
             return self._selected_series is not None
+        if action == 'toggle_expand':
+            return bool(
+                self._selected_series
+                and len(self._merge_tracked_revision(self._selected_series)) > 1
+            )
         if action in self._GATED_ACTIONS:
             if not self._selected_series:
                 return False
@@ -1581,10 +1777,19 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._checkout_new_series()
 
     def action_thread(self) -> None:
-        """View a series thread in the lite thread viewer."""
+        """View a series thread in the lite thread viewer.
+
+        A highlighted version row views that version's thread; otherwise
+        the thread of the revision the series tracks.
+        """
         if not self._selected_series:
             return
-        message_id = self._selected_series.get('message_id', '')
+        source = (
+            self._selected_revision
+            if self._selected_revision is not None
+            else self._selected_series
+        )
+        message_id = source.get('message_id', '')
         if not message_id:
             self.notify('No message-id available for this series', severity='error')
             return
@@ -1593,10 +1798,10 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             tracking_info = {
                 'identifier': self._identifier,
                 'change_id': self._selected_series.get('change_id', ''),
-                'revision': self._selected_series.get('revision', 1),
-                'is_rethreaded': bool(self._selected_series.get('is_rethreaded')),
+                'revision': source.get('revision', 1),
+                'is_rethreaded': bool(source.get('is_rethreaded')),
             }
-        self._focus_change_id = self._selected_series.get('change_id')
+        self._stash_focus()
         from b4.review_tui._lite_app import LiteThreadScreen
 
         self.push_screen(
@@ -1608,7 +1813,8 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             )
         )
 
-    def _merge_tracked_revision(self, series: Dict[str, Any]) -> List[Dict[str, Any]]:
+    @staticmethod
+    def _merge_tracked_revision(series: Dict[str, Any]) -> List[Dict[str, Any]]:
         """Known revisions of a series, guaranteed to include the tracked one.
 
         The revisions catalog is not guaranteed a row for the tracked
@@ -2042,8 +2248,14 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         # Exit to review mode
         self.exit(branch_name)
 
-    def _show_details(self, series: Dict[str, Any]) -> None:
+    def _show_details(
+        self, series: Dict[str, Any], rev: Optional[Dict[str, Any]] = None
+    ) -> None:
+        """Fill the details panel for *series*, or for one of its versions.
 
+        With *rev* given the version-specific fields (subject, link) come
+        from that revision instead of the tracked one.
+        """
         try:
             panel = self.query_one('#details-panel', Vertical)
         except NoMatches:
@@ -2051,6 +2263,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
 
         raw_subject = series.get('subject', '(no subject)')
         revision = series.get('revision', 1)
+        if rev is not None:
+            raw_subject = rev.get('subject') or raw_subject
+            revision = rev.get('revision', revision)
         num_patches = series.get('num_patches', 0) or 0
         ls = b4.LoreSubject(raw_subject)
         extras = ls.get_extra_prefixes(exclude=['patch'])
@@ -2066,7 +2281,10 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
 
         # Create link URL from message-id using linkmask
         link_url = ''
-        if message_id:
+        if rev is not None:
+            link_url = rev.get('link') or ''
+            message_id = rev.get('message_id', '')
+        if not link_url and message_id:
             config = b4.get_main_config()
             linkmask = config.get('linkmask', b4.LOREADDR + '/%s')
             if isinstance(linkmask, str) and '%s' in linkmask:
@@ -2149,6 +2367,16 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             else:
                 revisions_row.display = False
 
+        # Describe the highlighted version row, if any
+        version_row = self.query_one('#detail-version-row', Horizontal)
+        if rev is not None:
+            self.query_one('#detail-version', Static).update(
+                _format_version(rev, series)
+            )
+            version_row.display = True
+        else:
+            version_row.display = False
+
         # Show branch name for series with a review branch
         status = series.get('status', 'new')
         branch_row = self.query_one('#detail-branch-row', Horizontal)
@@ -2324,7 +2552,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         linkmask = str(config.get('linkmask', 'https://lore.kernel.org/r/%s'))
         topdir = b4.git_get_toplevel()
 
-        self._focus_change_id = self._selected_series.get('change_id')
+        self._stash_focus()
         self.push_screen(
             UpdateAllScreen(
                 [self._selected_series], self._identifier, linkmask, topdir
@@ -2345,8 +2573,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         # Skip snoozed series during update-all
         update_list = [s for s in self._all_series if s.get('status') != 'snoozed']
 
-        if self._selected_series:
-            self._focus_change_id = self._selected_series.get('change_id')
+        self._stash_focus()
         self.push_screen(
             UpdateAllScreen(update_list, self._identifier, linkmask, topdir),
             callback=self._on_update_complete,
@@ -2389,6 +2616,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         if self._selected_series is not None:
             panel.styles.height = 0
             self._selected_series = None
+            self._selected_revision = None
 
     def action_take(self) -> None:
         """Show take options dialog for the selected series."""
@@ -3589,6 +3817,15 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         change_id = self._selected_series.get('change_id', '')
         current_rev = self._selected_series.get('revision', 1)
 
+        # A highlighted version row already names the other side of the
+        # diff, so skip the picker.
+        if self._selected_revision is not None:
+            other_rev = self._selected_revision.get('revision')
+            if other_rev is not None and other_rev != current_rev:
+                with self.suspend():
+                    self._do_range_diff(change_id, current_rev, other_rev)
+                return
+
         try:
             conn = b4.review.tracking.get_db(self._identifier)
             revisions = b4.review.tracking.get_revisions(conn, change_id)
@@ -5107,8 +5344,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             self.notify(', '.join(parts) if parts else 'Queue empty')
             self._refresh_queue_indicator()
             if delivered_series:
-                if self._selected_series:
-                    self._focus_change_id = self._selected_series.get('change_id')
+                self._stash_focus()
                 self._load_series()
 
         self.push_screen(

-- 
2.53.0


  parent reply	other threads:[~2026-07-17 22:38 UTC|newest]

Thread overview: 14+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-17 22:37 [PATCH RFC 00/11] review: track and browse every version of a tracked series Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 01/11] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 02/11] review: track message counts for all revisions of a series Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 03/11] review: test per-revision message tracking Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 04/11] review-tui: poll every revision on u/U updates Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 05/11] review-tui: guarantee the tracked revision in revision lists Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 06/11] review: add backward discovery of older series revisions Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 07/11] review-tui: add a "Find older revisions" action Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 08/11] review: test backward revision discovery Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 09/11] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-07-17 22:37 ` Christian Brauner [this message]
2026-07-17 22:37 ` [PATCH RFC 11/11] review-tui: test per-version tracker rows Christian Brauner
2026-07-27 20:43 ` [PATCH RFC 00/11] review: track and browse every version of a tracked series Konstantin Ryabitsev
2026-07-27 21:27   ` Christian Brauner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260718-work-b4-multiver-rows-v1-10-3c539d2a3095@kernel.org \
    --to=brauner@kernel.org \
    --cc=konstantin@linuxfoundation.org \
    --cc=tools@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox