[PATCH b4 1/2] review-tui: set patchwork state on multiple series at once
Christian Brauner <[email protected]> Tue, 23 Jun 2026 11:27:05 +0200
| Newsgroups | org.kernel.linux.tools |
|---|---|
| Message-ID | <20260623-20260623-review-tui-pw-multiselect-v1-1-2470add1453c@kernel.org> |
In the Patchwork browser (b4 review -> p) the 's' key only ever set the state on the single highlighted series, so moving a stack of series to the same state -- e.g. marking a batch as Accepted -- meant repeating the pick once per row. Add a selection layer to the listing. Space marks or unmarks the highlighted series (and steps to the next row), 'a' marks or clears every row currently shown, and Esc drops all marks. Marked rows get a leading '*' and the title shows an "N selected" count. 's' now applies the chosen state to every marked series in one pass, falling back to the highlighted row when nothing is marked, so the single-series workflow is unchanged. Marks are resolved against the full listing, so they survive limit and hidden-toggle changes and still apply to rows scrolled out of the filter; they are pruned to the series that still exist after a refresh. The state PATCHes already ran over a flat list of patch ids, so the existing apply modal just receives the union across the marked series plus a summary label. Signed-off-by: Christian Brauner (Amutable) <[email protected]> --- src/b4/review_tui/_modals.py | 7 +- src/b4/review_tui/_pw_app.py | 201 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 173 insertions(+), 35 deletions(-) diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py index d383fdc..51e6b4e 100644 --- a/src/b4/review_tui/_modals.py +++ b/src/b4/review_tui/_modals.py @@ -350,10 +350,15 @@ PW_HELP_LINES = [ ' [bold]v[/bold] View series in modal\n', ' [bold]c[/bold] View CI check details\n', ' [bold]t[/bold] Track series for review\n', - ' [bold]s[/bold] Set Patchwork state\n', + ' [bold]s[/bold] Set state (marked series, or current)\n', ' [bold]h[/bold] Hide series\n', ' [bold]u[/bold] Unhide series (when showing hidden)\n', '\n', + '[bold]Select[/bold]\n', + ' [bold]Space[/bold] Mark/unmark series\n', + ' [bold]a[/bold] Mark/unmark all shown\n', + ' [bold]Esc[/bold] Clear marks\n', + '\n', '[bold]App[/bold]\n', ' [bold]r[/bold] Refresh series list\n', ' [bold]l[/bold] Filter series by pattern\n', diff --git a/src/b4/review_tui/_pw_app.py b/src/b4/review_tui/_pw_app.py index a6f680a..e70d017 100644 --- a/src/b4/review_tui/_pw_app.py +++ b/src/b4/review_tui/_pw_app.py @@ -72,11 +72,13 @@ def _format_series_label( tracked: bool, ts: Optional[Dict[str, str]] = None, show_delegate: bool = True, + selected: bool = False, ) -> Text: """Build a Text label for a series row. *ts* is a resolved theme styles dict from :func:`resolve_styles`. When *show_delegate* is ``False`` the delegate column is omitted. + *selected* marks the row for a bulk action with a leading ``*``. """ track_mark = 'T' if tracked else ' ' ci_state = series.get('check') or 'pending' @@ -96,6 +98,8 @@ def _format_series_label( submitter = pad_display(series.get('submitter') or 'Unknown', 30) name = series.get('name') or '(no subject)' text = Text() + sel_style = (f'bold {ts["warning"]}' if ts else 'bold') if selected else '' + text.append('*' if selected else ' ', style=sel_style) text.append(track_mark) text.append('\u25cf', style=ci_style) if show_delegate: @@ -112,12 +116,17 @@ class PwSeriesItem(ListItem): ACTION_REQUIRED_STATES = ('new', 'under-review') def __init__( - self, series: Dict[str, Any], tracked: bool = False, show_delegate: bool = True + self, + series: Dict[str, Any], + tracked: bool = False, + show_delegate: bool = True, + selected: bool = False, ) -> None: super().__init__() self.series = series self.tracked = tracked self.show_delegate = show_delegate + self.selected = selected state = series.get('state', 'new') if state not in self.ACTION_REQUIRED_STATES: self.add_class('--dimmed') @@ -128,11 +137,28 @@ class PwSeriesItem(ListItem): ts = resolve_styles(self.app) yield Label( _format_series_label( - self.series, self.tracked, ts, show_delegate=self.show_delegate + self.series, + self.tracked, + ts, + show_delegate=self.show_delegate, + selected=self.selected, ), markup=False, ) + def refresh_label(self) -> None: + """Re-render this row's label after a state/tracked/selected change.""" + ts = resolve_styles(self.app) + self.query_one(Label).update( + _format_series_label( + self.series, + self.tracked, + ts, + show_delegate=self.show_delegate, + selected=self.selected, + ) + ) + class PwApp(LoreNodeShutdownMixin, App[None]): """Textual app for browsing Patchwork series.""" @@ -228,6 +254,7 @@ class PwApp(LoreNodeShutdownMixin, App[None]): 'view': 'Series', 'ci_checks': 'Series', 'track_series': 'Series', + 'toggle_mark': 'Series', 'set_state': 'Series', 'hide_series': 'Series', 'refresh': 'App', @@ -242,9 +269,12 @@ class PwApp(LoreNodeShutdownMixin, App[None]): Binding('j', 'cursor_down', 'Down', show=False), Binding('k', 'cursor_up', 'Up', show=False), Binding('u', 'unhide_series', 'unhide', show=False), + Binding('a', 'mark_all', 'mark all', show=False), + Binding('escape', 'clear_marks', 'clear marks', show=False), # Series-specific actions Binding('c', 'ci_checks', 'ci checks'), Binding('t', 'track_series', 'track'), + Binding('space', 'toggle_mark', 'mark', key_display='space'), Binding('s', 'set_state', 'set state'), Binding('h', 'hide_series', 'hide'), # App-global actions @@ -272,9 +302,14 @@ class PwApp(LoreNodeShutdownMixin, App[None]): self._states: List[Dict[str, Any]] = [] self._hidden_ids: Set[int] = set() self._tracked_ids: Set[int] = set() + # Series ids marked for a bulk action (set state). Persists across + # limit/hidden toggles; pruned to still-existing series on (re)fetch. + self._selected_ids: Set[int] = set() self._show_hidden: bool = False self._limit_pattern: str = '' self._all_series: List[Dict[str, Any]] = [] + # Title without the trailing "· N selected" suffix, set on each refresh. + self._base_title: str = '' self._tracking_identifier: Optional[str] = None self._tracking_enabled: bool = False self._track_ctx: Optional[Dict[str, Any]] = None @@ -449,10 +484,12 @@ class PwApp(LoreNodeShutdownMixin, App[None]): ) async def _refresh_list(self) -> None: - title = self.query_one('#pw-title', Static) widgets = list(self.query('#pw-header, #pw-list')) for widget in widgets: await widget.remove() + # Drop marks for series that no longer exist (e.g. after a re-fetch). + valid_ids = {s.get('id') for s in self._all_series if s.get('id') is not None} + self._selected_ids &= valid_ids visible = [] hidden_count = 0 for s in self._all_series: @@ -486,24 +523,31 @@ class PwApp(LoreNodeShutdownMixin, App[None]): # deliberately not loaded, which would otherwise look like data loss. if self._window_days: base += f' · last {self._window_days} days' - title.update(base) + self._base_title = base + self._update_title_selection() if not visible: return # Hide the delegate column when all values are empty or identical delegates = {s.get('delegate', '') or '' for s, _h in visible} show_delegate = len(delegates) > 1 if show_delegate: - header_text = f' {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Delegate":<15s} {"Series"}' + header_text = f' {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Delegate":<15s} {"Series"}' else: header_text = ( - f' {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Series"}' + f' {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Series"}' ) header = Static(header_text, id='pw-header') items = [] for s, is_hidden in visible: sid = s.get('id') is_tracked = sid in self._tracked_ids if sid else False - item = PwSeriesItem(s, tracked=is_tracked, show_delegate=show_delegate) + is_selected = sid in self._selected_ids if sid else False + item = PwSeriesItem( + s, + tracked=is_tracked, + show_delegate=show_delegate, + selected=is_selected, + ) if is_hidden: item.add_class('--hidden') items.append(item) @@ -574,6 +618,74 @@ class PwApp(LoreNodeShutdownMixin, App[None]): except Exception: pass + def _visible_items(self) -> List['PwSeriesItem']: + """All series rows currently mounted in the list view.""" + try: + lv = self.query_one('#pw-list', ListView) + except Exception: + return [] + return [c for c in lv.children if isinstance(c, PwSeriesItem)] + + def _update_title_selection(self) -> None: + """Append a "· N selected" suffix to the title when marks are active.""" + try: + title = self.query_one('#pw-title', Static) + except Exception: + return + base = self._base_title + count = len(self._selected_ids) + if count: + base = f'{base} · {count} selected' + title.update(base) + + def action_toggle_mark(self) -> None: + """Mark/unmark the highlighted series, then advance the cursor.""" + item = self._get_highlighted_item() + if item is None: + return + sid = item.series.get('id') + if sid is None: + return + if sid in self._selected_ids: + self._selected_ids.discard(sid) + item.selected = False + else: + self._selected_ids.add(sid) + item.selected = True + item.refresh_label() + self._update_title_selection() + self.action_cursor_down() + + def action_mark_all(self) -> None: + """Mark every visible series, or unmark them all if already marked.""" + items = self._visible_items() + if not items: + return + all_marked = all(item.series.get('id') in self._selected_ids for item in items) + for item in items: + sid = item.series.get('id') + if sid is None: + continue + if all_marked: + self._selected_ids.discard(sid) + item.selected = False + else: + self._selected_ids.add(sid) + item.selected = True + item.refresh_label() + self._update_title_selection() + + def action_clear_marks(self) -> None: + """Drop all marks (Esc).""" + if not self._selected_ids: + return + self._selected_ids.clear() + for item in self._visible_items(): + if item.selected: + item.selected = False + item.refresh_label() + self._update_title_selection() + def action_view(self) -> None: """View a Patchwork series thread in the lite thread viewer.""" item = self._get_highlighted_item() @@ -614,58 +726,84 @@ class PwApp(LoreNodeShutdownMixin, App[None]): return child return None + def _state_targets(self) -> List[Dict[str, Any]]: + """Series the set-state action applies to. + + All marked series when any are marked, otherwise the highlighted row. + Marked series are resolved from the full listing so the action still + covers rows hidden by the current limit filter. + """ + if self._selected_ids: + return [s for s in self._all_series if s.get('id') in self._selected_ids] + item = self._get_highlighted_item() + return [item.series] if item is not None else [] + def action_set_state(self) -> None: if not self._states: self.notify('States not loaded yet', severity='warning') return - item = self._get_highlighted_item() - if item is None: + targets = self._state_targets() + if not targets: return - current_state = item.series.get('state', 'new') + item = self._get_highlighted_item() + current_state = item.series.get('state', 'new') if item is not None else 'new' self.push_screen( SetStateScreen(self._states, current_state), - callback=lambda result: self._on_set_state(result, item), + callback=lambda result: self._on_set_state(result, targets), ) def _on_set_state( - self, result: Optional[Tuple[str, bool]], item: 'PwSeriesItem' + self, result: Optional[Tuple[str, bool]], targets: List[Dict[str, Any]] ) -> None: if result is None: return new_state, archived = result - patch_ids = item.series.get('patch_ids', []) + patch_ids: List[int] = [] + for s in targets: + patch_ids.extend(s.get('patch_ids', [])) if not patch_ids: - self.notify('No patch IDs for this series', severity='warning') + self.notify('No patch IDs for selected series', severity='warning') return - series_name = item.series.get('name', '(no subject)') + if len(targets) == 1: + label = targets[0].get('name', '(no subject)') + else: + label = f'{len(targets)} series ({len(patch_ids)} patches)' self.push_screen( ApplyStateModal( - self._pwkey, self._pwurl, patch_ids, new_state, archived, series_name + self._pwkey, self._pwurl, patch_ids, new_state, archived, label ), - callback=lambda res: self._on_apply_complete(res, item), + callback=lambda res: self._on_apply_complete(res, targets), ) def _on_apply_complete( - self, result: Optional[Tuple[int, int, str]], item: 'PwSeriesItem' + self, result: Optional[Tuple[int, int, str]], targets: List[Dict[str, Any]] ) -> None: assert result is not None ok, fail, new_state = result if fail: self.notify(f'{ok} updated, {fail} failed', severity='warning') - else: + elif len(targets) == 1: self.notify(f'{ok} patch(es) set to {new_state}', severity='information') - item.series['state'] = new_state - if new_state in PwSeriesItem.ACTION_REQUIRED_STATES: - item.remove_class('--dimmed') else: - item.add_class('--dimmed') - ts = resolve_styles(self) - item.query_one(Label).update( - _format_series_label( - item.series, item.tracked, ts, show_delegate=item.show_delegate + self.notify( + f'{len(targets)} series set to {new_state} ({ok} patches)', + severity='information', ) - ) + mounted = {item.series.get('id'): item for item in self._visible_items()} + for s in targets: + s['state'] = new_state + item = mounted.get(s.get('id')) + if item is None: + continue + item.selected = False + if new_state in PwSeriesItem.ACTION_REQUIRED_STATES: + item.remove_class('--dimmed') + else: + item.add_class('--dimmed') + item.refresh_label() + self._selected_ids.clear() + self._update_title_selection() def action_track_series(self) -> None: if not self._tracking_enabled: @@ -777,12 +915,7 @@ class PwApp(LoreNodeShutdownMixin, App[None]): self._tracked_ids.add(pw_series_id) item.tracked = True item.add_class('--tracked') - ts = resolve_styles(self) - item.query_one(Label).update( - _format_series_label( - item.series, True, ts, show_delegate=item.show_delegate - ) - ) + item.refresh_label() self.notify( f'Started tracking: {series_name}', severity='information', timeout=3 ) -- 2.53.0