From 83d4a08c6feda676db71f3d147e0a03dd9533328 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 11 Nov 2026 06:25:16 +0000 Subject: [PATCH] feat(zbugs-rune): searchable people pickers; filter and create labels in the picker The assignee picker and the creator and assignee filters are Rune comboboxes searchable by login, as the web app's user picker. The label picker filters the project's labels, lists the issue's first, and creates a label from the filter when none matches, as the web app's, in place of the New Label sheet. Escape closes an open picker. Co-authored-by: tmm --- apps/zbugs-rune/e2e.sh | 11 +-- apps/zbugs-rune/src/main.rs | 65 ++++++++----- apps/zbugs-rune/src/view.rs | 186 ++++++++++++++++++++++++------------ 3 files changed, 168 insertions(+), 94 deletions(-) diff --git a/apps/zbugs-rune/e2e.sh b/apps/zbugs-rune/e2e.sh index a6eaff202a..73c76d1631 100755 --- a/apps/zbugs-rune/e2e.sh +++ b/apps/zbugs-rune/e2e.sh @@ -22,8 +22,7 @@ out=$(mktemp -d) issue=$(sql "select id from issue i where open and not exists (select 1 from comment c where c.\"issueID\" = i.id) order by length(description), \"shortID\" limit 1") -# The label menu lists "New Label…", a separator, then the issue's project's -# labels by name, bytewise (item 2 is the first). +# The label picker lists the issue's project's labels by name, bytewise. first_label=$(sql "select l.name from label l join issue i on i.\"projectID\" = l.\"projectID\" where i.id = '$issue' order by l.name collate \"C\" limit 1") sql "delete from \"issueLabel\" where \"issueID\" = '$issue'" @@ -48,16 +47,16 @@ cat > "$out/script.json" <, @@ -284,7 +287,10 @@ impl App { title_edit: TextEdit::new(""), description_edit: TextEdit::new(""), token_edit: TextEdit::new(""), - label_edit: TextEdit::new(""), + label_search: TextEdit::new(""), + creator_combo: ComboboxState::default(), + assignee_filter_combo: ComboboxState::default(), + assignee_combo: ComboboxState::default(), cache_edit: TextEdit::new(""), api_edit: TextEdit::new(""), sheet_error: None, @@ -727,15 +733,17 @@ impl App { } } - /// Creates a label in the issue's project and adds it to the issue. - pub fn save_label(&mut self) { - let name = self.label_edit.text.trim().to_owned(); - if name.is_empty() { - self.sheet_error = Some("A label needs a name.".into()); - return; - } - if self.labels.iter().any(|l| l.name == name) { - self.sheet_error = Some(format!("There's already a label named {name}.")); + /// Creates a label named as the label picker's filter, in the issue's + /// project, and adds it to the issue, unless a label has that name + /// (whatever its case), as the web picker does. + pub fn create_label(&mut self) { + let name = self.label_search.text.trim().to_owned(); + if name.is_empty() + || self + .labels + .iter() + .any(|l| l.name.to_lowercase() == name.to_lowercase()) + { return; } let Some(issue) = &self.detail else { return }; @@ -745,7 +753,8 @@ impl App { label_name: name, project_id: None, })); - self.sheet = None; + self.label_search = TextEdit::new(""); + self.label_search.focused = true; } pub fn toggle_open(&mut self) { @@ -845,9 +854,16 @@ impl App { } pub fn toggle_picker(&mut self, picker: Picker) { - if matches!(picker, Picker::Emoji(_)) { - self.emoji_search = TextEdit::new(""); - self.emoji_search.focused = true; + match picker { + Picker::Emoji(_) => { + self.emoji_search = TextEdit::new(""); + self.emoji_search.focused = true; + } + Picker::Labels => { + self.label_search = TextEdit::new(""); + self.label_search.focused = true; + } + _ => {} } self.picker = (self.picker.as_ref() != Some(&picker)).then_some(picker); } @@ -961,11 +977,6 @@ impl App { self.title_edit = TextEdit::new(issue.title.clone()); self.description_edit = TextEdit::new(issue.description.clone()); } - Sheet::NewLabel => { - self.picker = None; - self.label_edit = TextEdit::new(""); - self.label_edit.focused = true; - } Sheet::Login => self.token_edit = TextEdit::new(""), Sheet::Servers => { self.cache_edit = TextEdit::new(self.settings.cache_url.clone()); @@ -1104,8 +1115,9 @@ fn apply_issues(s: &mut App, rows: Vec, result_type: ResultType, tx: &mut } } -/// Keys no control took: j and k step through the list, and / starts a -/// search, as on the web. +/// Keys no control took: j and k step through the list, / starts a search, +/// as on the web, and Escape closes an open picker (Rune's popovers leave +/// Escape to the app). fn on_key(s: &mut App, event: &rune::KeyEvent, tx: &mut Tx) -> bool { if s.sheet.is_some() || s.confirm.is_some() @@ -1116,6 +1128,7 @@ fn on_key(s: &mut App, event: &rune::KeyEvent, tx: &mut Tx) -> bool { return false; } match &event.key { + rune::Key::Escape if s.picker.is_some() => s.picker = None, rune::Key::Text(t) if t == "j" => s.step(1, tx), rune::Key::Text(t) if t == "k" => s.step(-1, tx), rune::Key::Text(t) if t == "/" => s.search.focused = true, diff --git a/apps/zbugs-rune/src/view.rs b/apps/zbugs-rune/src/view.rs index dde96acc7e..6a66cc6bff 100644 --- a/apps/zbugs-rune/src/view.rs +++ b/apps/zbugs-rune/src/view.rs @@ -4,10 +4,10 @@ use std::borrow::Cow; use std::sync::OnceLock; -use rune::{Cx, El, GrowthSpec, ScrollSpec, col, el, row, text}; +use rune::{Cx, El, GrowthSpec, ScrollSpec, Tx, col, el, row, text}; use rune_kit::{ - Button, ButtonVariant, Checkbox, Dialog, Kit, Markdown, Menu, MenuItem, Popover, - SegmentedControl, Select, TextArea, TextInput, + Button, ButtonVariant, Checkbox, Combobox, ComboboxOption, ComboboxState, Dialog, Kit, + Markdown, Menu, MenuItem, Popover, SegmentedControl, Select, TextArea, TextInput, }; use zbugs_model::args::{SortDirection, SortField}; use zbugs_model::schema::{Comment, Emoji, Issue, IssueVisibility, Label, User}; @@ -167,7 +167,7 @@ fn sidebar(s: &App, k: &Kit, layer: &mut Vec) -> El { s, k, layer, - Picker::CreatorFilter, + true, &s.creators, &s.filter.creator, )) @@ -176,7 +176,7 @@ fn sidebar(s: &App, k: &Kit, layer: &mut Vec) -> El { s, k, layer, - Picker::AssigneeFilter, + false, &s.assignees, &s.filter.assignee, )) @@ -257,34 +257,32 @@ fn project_picker(s: &App, k: &Kit, layer: &mut Vec) -> El { .build(k, layer) } -/// Filters the list by who created the issues, or who they're assigned to. +/// Filters the list by who created the issues (`creator`), or who they're +/// assigned to, searchable as the web app's user picker. fn user_filter( s: &App, k: &Kit, layer: &mut Vec, - picker: Picker, + creator: bool, users: &[User], current: &Option, ) -> El { const ANYONE: &str = "Anyone"; - let mut options = vec![ANYONE]; - options.extend(users.iter().map(|u| u.login.as_str())); + let mut logins = vec![ANYONE]; + logins.extend(users.iter().map(|u| u.login.as_str())); // A login from before the users synced, or from the "me" shortcut. - if let Some(c) = current.as_deref().filter(|c| !options.contains(c)) { - options.push(c); + if let Some(c) = current.as_deref().filter(|c| !logins.contains(c)) { + logins.push(c); } - let creator = picker == Picker::CreatorFilter; - let key = if creator { - "creator" + let (key, state, access): (_, _, Combo) = if creator { + ("creator", &s.creator_combo, |s| &mut s.creator_combo) } else { - "assignee-filter" + ("assignee-filter", &s.assignee_filter_combo, |s| { + &mut s.assignee_filter_combo + }) }; - Select::new(key, &options) - .width(202.0) - .current(Some(current.as_deref().unwrap_or(ANYONE))) - .open(s.picker.as_ref() == Some(&picker)) - .on_toggle(move |s: &mut App| s.toggle_picker(picker.clone())) - .on_close(|s: &mut App| s.picker = None) + let current = current.as_deref().unwrap_or(ANYONE); + people(k, (key, state, access), &logins, current, 202.0) .on_choose(move |s: &mut App, login: &str, _| { let login = (login != ANYONE).then(|| login.to_owned()); if creator { @@ -296,6 +294,30 @@ fn user_filter( .build(k, layer) } +/// Where a people picker keeps its state in the app. +type Combo = for<'a> fn(&'a mut App) -> &'a mut ComboboxState; + +/// A searchable picker of `logins`, its trigger `width` points wide and +/// showing `current` as the other dropdowns do. +fn people( + k: &Kit, + (key, state, access): (&str, &ComboboxState, Combo), + logins: &[&str], + current: &str, + width: f32, +) -> Combobox { + let options: Vec = logins.iter().map(|l| ComboboxOption::new(*l, *l)).collect(); + let trigger = Select::new(format!("{key}-value"), &[] as &[&str]) + .width(width) + .current(Some(current)) + .open(state.is_open()) + .trigger(k); + Combobox::new(key, state, &options, access) + .current(Some(current)) + .search_placeholder("Search people") + .trigger(trigger) +} + /// Filters the list by labels, checked when it does. fn label_filter(s: &App, k: &Kit, layer: &mut Vec) -> El { let open = s.picker == Some(Picker::LabelFilter); @@ -890,22 +912,23 @@ fn comment(s: &App, c: &Comment, k: &Kit, layer: &mut Vec) -> El { /// Who the issue is assigned to, for those who can change it. fn assignee_picker(s: &App, issue: &Issue, k: &Kit, layer: &mut Vec) -> El { const NOBODY: &str = "Nobody"; - let mut options = vec![NOBODY]; - options.extend(s.crew.iter().map(|u| u.login.as_str())); + let mut logins = vec![NOBODY]; + logins.extend(s.crew.iter().map(|u| u.login.as_str())); let current = issue.assignee.as_ref().map_or(NOBODY, |u| u.login.as_str()); row() .gap(8.0) .center_y() .child(field_label(k, "Assignee")) .child( - Select::new("assignee", &options) - .width(200.0) - .current(Some(current)) - .open(s.picker == Some(Picker::Assignee)) - .on_toggle(|s: &mut App| s.toggle_picker(Picker::Assignee)) - .on_close(|s: &mut App| s.picker = None) - .on_choose(|s: &mut App, login: &str, _| s.assign(login)) - .build(k, layer), + people( + k, + ("assignee", &s.assignee_combo, |s| &mut s.assignee_combo), + &logins, + current, + 200.0, + ) + .on_choose(|s: &mut App, login: &str, _| s.assign(login)) + .build(k, layer), ) } @@ -935,10 +958,12 @@ fn choice( ) } -/// The project's labels, checked when the issue has them. +/// The project's labels, the issue's first, filtered by what the user types +/// as the web app's label picker. A filter no label matches offers to create +/// a label with that name, and so does Enter. fn label_picker(s: &App, issue_labels: &[Label], k: &Kit, layer: &mut Vec) -> El { let open = s.picker == Some(Picker::Labels); - let mut menu = Menu::new("labels") + let mut popover = Popover::new("labels") .trigger( Button::new("Labels…") .variant(ButtonVariant::Ghost) @@ -946,20 +971,65 @@ fn label_picker(s: &App, issue_labels: &[Label], k: &Kit, layer: &mut Vec) - .on_press(|s: &mut App| s.toggle_picker(Picker::Labels)) .build(k), ) - .min_width(200.0) + .label("Labels") .open(open) - .on_close(|s: &mut App| s.picker = None) - .item(MenuItem::new("New Label…").on_select(|s: &mut App| s.open_sheet(Sheet::NewLabel))) - .separator(); - for l in &s.labels { + .on_close(|s: &mut App| s.picker = None); + if !open { + return popover.build(k, layer); + } + let query = s.label_search.text.trim().to_lowercase(); + let on = |l: &Label| issue_labels.iter().any(|x| x.id == l.id); + let (mine, others): (Vec<&Label>, Vec<&Label>) = s + .labels + .iter() + .filter(|l| l.name.to_lowercase().contains(&query)) + .partition(|l| on(l)); + let mut options = col().key("label-options").w_full().gap(4.0); + for l in mine.iter().chain(&others) { let id = l.id.clone(); - menu = menu.item( - MenuItem::new(l.name.clone()) - .checked(issue_labels.iter().any(|x| x.id == l.id)) - .on_select(move |s: &mut App| s.toggle_label(&id)), + options = options.child( + Checkbox::new(on(l)) + .label(l.name.clone()) + .on_toggle(move |s: &mut App| s.toggle_label(&id)) + .build(k) + .key(format!("label-{}", l.name)), ); } - menu.build(k, layer) + if !query.is_empty() && mine.is_empty() && others.is_empty() { + options = options.child( + Button::new(format!("Create \"{}\"", s.label_search.text.trim())) + .variant(ButtonVariant::Ghost) + .on_press(|s: &mut App| s.create_label()) + .build(k) + .key("create-label"), + ); + } + popover = popover.child( + col() + .w(240.0) + .gap(8.0) + .child( + TextInput::new(&s.label_search) + .placeholder("Filter or add label…") + .width(None) + .on_edit(|s: &mut App| &mut s.label_search) + .on_submit(|s: &mut App, _| s.create_label()) + .on_key(close_on_escape) + .build(k) + .key("label-search"), + ) + .child( + col() + .key("label-scroll") + .w_full() + .max_h(240.0) + .scroll(ScrollSpec { + stick_bottom: false, + }) + .child(options), + ), + ); + popover.build(k, layer) } /// The reactions to an issue or a comment, one pill per emoji with its @@ -1037,6 +1107,16 @@ fn reactions(s: &App, subject_id: &str, k: &Kit, layer: &mut Vec) -> El { r.child(popover.build(k, layer)) } +/// Escape in a picker's search field closes the picker, which the field +/// would otherwise keep for itself. +fn close_on_escape(s: &mut App, event: &rune::KeyEvent, _: &mut Tx) -> bool { + if !matches!(event.key, rune::Key::Escape) { + return false; + } + s.picker = None; + true +} + /// Who reacted with an emoji, as the web app's tooltip says it: "you, arv /// and holden reacted with rocket", naming ten and counting the rest past /// eleven. @@ -1129,6 +1209,7 @@ fn emoji_picker(s: &App, subject_id: &str, k: &Kit) -> El { TextInput::new(&s.emoji_search) .placeholder("Search") .on_edit(|s: &mut App| &mut s.emoji_search) + .on_key(close_on_escape) .build(k) .key("emoji-search"), ) @@ -1250,25 +1331,6 @@ fn sheets(s: &App, k: &Kit, layer: &mut Vec) { .child(form(fields)) .build(k, layer); } - Some(Sheet::NewLabel) => { - let mut fields = vec![ - TextInput::new(&s.label_edit) - .placeholder("Name") - .on_edit(|s: &mut App| &mut s.label_edit) - .on_submit(|s: &mut App, _| s.save_label()) - .build(k) - .key("label-field"), - ]; - fields.extend(error(s)); - fields.push(buttons("Create", App::save_label)); - Dialog::new("label-sheet", "New Label") - .width(400.0) - .fit() - .open(true) - .on_close(close) - .child(form(fields)) - .build(k, layer); - } Some(Sheet::Login) => { let waiting = s.github_login.is_some(); let mut fields = vec![