feat: add persistent pull request sort and filter menus

This commit is contained in:
Valdemar Lorenzen
2026-10-21 13:30:38 +02:00
parent 37b47ebc8a
commit fbcb519e13
22 changed files with 1557 additions and 181 deletions
+187 -95
View File
@@ -1,5 +1,6 @@
use crate::editor::{BackspaceKind, Input};
use crate::files::{FilePicker, FilePresentation, FileView, fuzzy_match};
use crate::pull_requests::{Direction, Preferences, ReviewFilter, SortField};
use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
@@ -109,37 +110,43 @@ pub enum PullRequestReviewState {
Requested,
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub(crate) enum PullRequestReviewScope {
#[default]
All,
Requested,
Approved,
#[derive(Clone, Debug, Default)]
pub struct PullRequestMetadata {
pub created_at: String,
pub updated_at: String,
pub requested: bool,
pub approved: bool,
}
impl PullRequestReviewScope {
pub(crate) fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Requested => "Requested",
Self::Approved => "Approved",
impl PullRequestMetadata {
fn filter(&self) -> ReviewFilter {
if self.requested {
ReviewFilter::Requested
} else if self.approved {
ReviewFilter::Approved
} else {
ReviewFilter::Other
}
}
}
fn next(self) -> Self {
match self {
Self::All => Self::Requested,
Self::Requested => Self::Approved,
Self::Approved => Self::All,
}
}
#[derive(Clone, Debug)]
pub(crate) struct PullRequestMenu {
pub sort: bool,
pub selected: usize,
pub draft: Preferences,
}
fn matches(self, state: PullRequestReviewState) -> bool {
match self {
Self::All => true,
Self::Requested => state == PullRequestReviewState::Requested,
Self::Approved => state == PullRequestReviewState::Approved,
}
impl PullRequestMenu {
fn set_rank(&mut self, rank: Option<u8>) {
let field = self.draft.ordered_fields()[self.selected];
self.draft.set_rank(field, rank);
self.selected = self
.draft
.ordered_fields()
.iter()
.position(|&candidate| candidate == field)
.expect("four sort fields");
}
}
@@ -147,7 +154,7 @@ impl PullRequestReviewScope {
pub struct PullRequestPage {
pub kind: RepoKind,
pub remote: String,
pub entries: Vec<(Revision, String, PullRequestReviewState)>,
pub entries: Vec<(Revision, PullRequestMetadata)>,
pub has_more: bool,
}
@@ -614,10 +621,11 @@ pub struct DiffState {
pub catalog: Option<Catalog>,
pub pull_request_remote: Option<String>,
pull_request_categories: [PullRequestCategory; 2],
pull_request_order: BTreeMap<u64, (String, usize, PullRequestReviewState)>,
pull_request_order: BTreeMap<u64, (PullRequestMetadata, usize)>,
pub(crate) pull_request_retries: [u64; 2],
pub(crate) pull_request_filter: PullRequestReviewScope,
pub(crate) pull_request_priority: PullRequestReviewScope,
pub(crate) pull_request_preferences: Preferences,
pub(crate) pull_request_menu: Option<PullRequestMenu>,
pub(crate) pull_request_preferences_changed: bool,
pub phase: Phase,
pub word_wrap: bool,
pub view_mode: ViewMode,
@@ -682,8 +690,9 @@ impl DiffState {
pull_request_categories: Default::default(),
pull_request_order: BTreeMap::new(),
pull_request_retries: [0; 2],
pull_request_filter: PullRequestReviewScope::All,
pull_request_priority: PullRequestReviewScope::All,
pull_request_preferences: Preferences::default(),
pull_request_menu: None,
pull_request_preferences_changed: false,
phase: Phase::Before,
word_wrap: false,
view_mode: ViewMode::Split,
@@ -980,37 +989,19 @@ impl DiffState {
entries: Vec::new(),
notice: Some(format!("Pull requests from {}", page.remote)),
});
for (revision, created_at, review_state) in page.entries {
for (revision, metadata) in page.entries {
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
continue;
};
if let Some((date, index, state)) = self.pull_request_order.get_mut(number)
{
*date = created_at;
*state = review_state;
if let Some((details, index)) = self.pull_request_order.get_mut(number) {
*details = metadata;
catalog.entries[*index] = revision;
} else {
self.pull_request_order
.insert(*number, (created_at, catalog.entries.len(), review_state));
.insert(*number, (metadata, catalog.entries.len()));
catalog.entries.push(revision);
}
}
let number = |revision: &Revision| match revision.target {
RevisionTarget::PullRequest { number, .. } => number,
_ => unreachable!("pull request catalog"),
};
catalog.entries.sort_unstable_by(|a, b| {
self.pull_request_order[&number(b)]
.0
.cmp(&self.pull_request_order[&number(a)].0)
.then_with(|| number(b).cmp(&number(a)))
});
for (index, revision) in catalog.entries.iter().enumerate() {
self.pull_request_order
.get_mut(&number(revision))
.expect("known pull request")
.1 = index;
}
self.prepare_search();
self.refilter();
if let Some(target) = selected {
@@ -1271,12 +1262,35 @@ impl DiffState {
}
pub(crate) fn pull_request_review_state(&self, revision: &Revision) -> PullRequestReviewState {
if let RevisionTarget::PullRequest { number, .. } = revision.target {
self.pull_request_order
.get(&number)
.map_or(PullRequestReviewState::None, |entry| entry.2)
} else {
PullRequestReviewState::None
match self
.pull_request_metadata(revision)
.map(PullRequestMetadata::filter)
{
Some(ReviewFilter::Requested) => PullRequestReviewState::Requested,
Some(ReviewFilter::Approved) => PullRequestReviewState::Approved,
_ => PullRequestReviewState::None,
}
}
fn pull_request_metadata(&self, revision: &Revision) -> Option<&PullRequestMetadata> {
let RevisionTarget::PullRequest { number, .. } = revision.target else {
return None;
};
self.pull_request_order.get(&number).map(|entry| &entry.0)
}
pub(crate) fn set_pull_request_preferences(&mut self, preferences: Preferences) {
if self.pull_request_preferences == preferences {
return;
}
let selected = self
.entries()
.nth(self.selected)
.map(|entry| entry.target.clone());
self.pull_request_preferences = preferences;
self.refilter();
if let Some(target) = selected {
self.select_target(&target);
}
}
@@ -1363,9 +1377,11 @@ impl DiffState {
Category::Open | Category::Closed | Category::Drafts
)))
|| (self.pull_request_remote.is_some()
&& !self
.pull_request_filter
.matches(self.pull_request_review_state(entry)))
&& !self.pull_request_preferences.filters.contains(
&self
.pull_request_metadata(entry)
.map_or(ReviewFilter::Other, PullRequestMetadata::filter),
))
{
continue;
}
@@ -1401,26 +1417,50 @@ impl DiffState {
self.search_matches.push((score, index));
}
}
let priority = |index: usize| {
if self.pull_request_priority == PullRequestReviewScope::All {
return false;
}
let RevisionTarget::PullRequest { number, .. } = catalog.entries[index].target
else {
return false;
};
!self
.pull_request_priority
.matches(self.pull_request_order[&number].2)
};
if self.pull_request_priority != PullRequestReviewScope::All {
self.visible
.sort_unstable_by_key(|&index| (priority(index), index));
if self.pull_request_remote.is_none() {
self.search_matches
.sort_unstable_by_key(|&(score, index)| (score, index));
}
self.search_matches
.sort_unstable_by_key(|&(score, index)| (priority(index), score, index));
self.visible
.extend(self.search_matches.iter().map(|(_, index)| *index));
if self.pull_request_remote.is_some() {
let fields = self.pull_request_preferences.ordered_fields().map(|field| {
(
field,
self.pull_request_preferences
.sort
.get(&field)
.map_or(Direction::Desc, |rule| rule.direction),
)
});
self.visible.sort_unstable_by(|&a, &b| {
let number = |index: usize| match catalog.entries[index].target {
RevisionTarget::PullRequest { number, .. } => number,
_ => unreachable!("pull request catalog"),
};
let a_number = number(a);
let b_number = number(b);
let a = &self.pull_request_order[&a_number].0;
let b = &self.pull_request_order[&b_number].0;
for (field, direction) in fields {
let order = match field {
SortField::Requested => b.requested.cmp(&a.requested),
SortField::UpdatedAt => b.updated_at.cmp(&a.updated_at),
SortField::Approved => b.approved.cmp(&a.approved),
SortField::CreatedAt => b.created_at.cmp(&a.created_at),
};
let order = if direction == Direction::Asc {
order.reverse()
} else {
order
};
if !order.is_eq() {
return order;
}
}
b_number.cmp(&a_number)
});
}
}
self.selected = 0;
}
@@ -1433,6 +1473,15 @@ impl DiffState {
}
pub(crate) fn navigate(&mut self, forward: bool) {
if let Some(menu) = &mut self.pull_request_menu {
let count = if menu.sort { 4 } else { 3 };
menu.selected = if forward {
(menu.selected + 1) % count
} else {
(menu.selected + count - 1) % count
};
return;
}
if self.phase == Phase::View {
if self.focus == Focus::Files {
if let Some(files) = &mut self.files {
@@ -1463,6 +1512,20 @@ impl DiffState {
/// Handles selector filtering and comparison navigation; the editor owns selector closing.
pub(crate) fn handle(&mut self, input: Input) {
if self.pull_request_remote.is_some() && self.phase != Phase::View {
if matches!(input, Input::Ctrl('s' | 'f')) {
self.pull_request_menu = Some(PullRequestMenu {
sort: input == Input::Ctrl('s'),
selected: 0,
draft: self.pull_request_preferences.clone(),
});
return;
}
if self.pull_request_menu.is_some() {
self.handle_pull_request_menu(input);
return;
}
}
if input == Input::Escape {
if self.phase == Phase::View
&& self.focus == Focus::Files
@@ -1506,22 +1569,6 @@ impl DiffState {
self.choose()
}
Input::Ctrl('r') if self.pull_request_remote.is_some() => self.retry_pull_requests(),
Input::Ctrl('f' | 's') if self.pull_request_remote.is_some() => {
let selected = self
.entries()
.nth(self.selected)
.map(|entry| entry.target.clone());
let scope = if input == Input::Ctrl('f') {
&mut self.pull_request_filter
} else {
&mut self.pull_request_priority
};
*scope = scope.next();
self.refilter();
if let Some(target) = selected {
self.select_target(&target);
}
}
input if BackspaceKind::from_input(&input).is_some() => {
if BackspaceKind::from_input(&input)
.expect("guard checked backspace input")
@@ -1542,6 +1589,51 @@ impl DiffState {
}
}
fn handle_pull_request_menu(&mut self, input: Input) {
let menu = self.pull_request_menu.as_mut().expect("active menu");
match input {
Input::Escape => self.pull_request_menu = None,
Input::Enter => {
let preferences = self.pull_request_menu.take().expect("active menu").draft;
self.set_pull_request_preferences(preferences);
self.pull_request_preferences_changed = true;
}
Input::Up | Input::Ctrl('k') => self.navigate(false),
Input::Down | Input::Ctrl('j') => self.navigate(true),
Input::Home => menu.selected = 0,
Input::End => menu.selected = if menu.sort { 3 } else { 2 },
Input::Char('1'..='4') if menu.sort => {
let Input::Char(digit) = input else {
unreachable!()
};
menu.set_rank(Some(digit as u8 - b'0'));
}
input if menu.sort && BackspaceKind::from_input(&input).is_some() => {
menu.set_rank(None);
}
Input::Left | Input::Right if menu.sort => {
let field = menu.draft.ordered_fields()[menu.selected];
if field.is_date() {
let direction = &mut menu.draft.sort.entry(field).or_default().direction;
*direction = if *direction == Direction::Desc {
Direction::Asc
} else {
Direction::Desc
};
}
}
Input::Char(' ') if !menu.sort => {
let filter = ReviewFilter::ALL[menu.selected];
if menu.draft.filters.contains(&filter) {
menu.draft.filters.retain(|item| *item != filter);
} else {
menu.draft.filters.push(filter);
}
}
_ => {}
}
}
fn choose(&mut self) {
let revision = self.entries().nth(self.selected).cloned();
let Some(revision) = revision else {
+49 -1
View File
@@ -2286,6 +2286,9 @@ pub struct Editor {
diff_display_options: (ViewMode, SplitAxis),
pub(crate) pull_request_review_requested_symbol: String,
pub(crate) pull_request_approved_symbol: String,
pub(crate) pull_request_preferences: crate::pull_requests::Preferences,
pub(crate) pull_request_preferences_changed: bool,
pub(crate) pull_request_configured: [bool; 2],
next_diff_request: u64,
outstanding_diff_request: Option<u64>,
pending_lsp_navigation: Option<PendingLspNavigation>,
@@ -2428,6 +2431,9 @@ impl Editor {
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
pull_request_preferences: Default::default(),
pull_request_preferences_changed: false,
pull_request_configured: [false; 2],
next_diff_request: 0,
outstanding_diff_request: None,
pending_lsp_navigation: None,
@@ -5318,6 +5324,46 @@ impl Editor {
let phase = self.picker_diff().expect("active diff").phase;
let files_focused =
phase == Phase::View && self.picker_diff().unwrap().focus == Focus::Files;
if phase != Phase::View
&& self.picker_diff().unwrap().pull_request_remote.is_some()
&& (self.picker_diff().unwrap().pull_request_menu.is_some()
|| matches!(input, Input::Ctrl('s' | 'f')))
{
if !matches!(
input,
Input::Char('0'..='9' | ' ')
| Input::Ctrl('s' | 'f')
| Input::Escape
| Input::Enter
| Input::Left
| Input::Right
) && BackspaceKind::from_input(&input).is_none()
&& self.handle_selection_binding(&input)
{
return None;
}
let diff = self.picker_diff_mut().unwrap();
diff.handle(input);
if std::mem::take(&mut diff.pull_request_preferences_changed) {
let preferences = diff.pull_request_preferences.clone();
self.pull_request_preferences = preferences.clone();
self.pull_request_preferences_changed = true;
for diff in self
.diff
.iter_mut()
.chain(self.parked_diff.iter_mut())
.chain(
self.tabs
.values_mut()
.flat_map(|tab| tab.diff.iter_mut().chain(tab.parked_diff.iter_mut())),
)
.filter(|diff| diff.pull_request_remote.is_some())
{
diff.set_pull_request_preferences(preferences.clone());
}
}
return None;
}
if phase != Phase::View && input == Input::Escape {
return self.execute_action(Action::DiffClose.entry(), "");
}
@@ -10172,7 +10218,9 @@ impl Editor {
if args.split_whitespace().count() > 1 {
self.set_message("Usage: pull_requests [remote]");
} else {
self.open_diff(DiffState::pull_requests(args.into()));
let mut diff = DiffState::pull_requests(args.into());
diff.set_pull_request_preferences(self.pull_request_preferences.clone());
self.open_diff(diff);
}
} else if args.is_empty() {
self.open_diff(DiffState::new());
+6 -4
View File
@@ -17,6 +17,7 @@ pub(crate) struct PullRequestSummary {
pub approved: bool,
pub review_requested: bool,
pub created_at: String,
pub updated_at: String,
}
struct PullRequestMetadata {
@@ -908,9 +909,9 @@ fn gitea_head(value: &Value, id: u64) -> Result<String, String> {
sha(object, "sha")
}
fn created_at(value: &Value) -> Result<String, String> {
let invalid = || "Forge API returned invalid created_at".to_owned();
let text = field(value, "created_at")?;
fn timestamp(value: &Value, key: &str) -> Result<String, String> {
let invalid = || format!("Forge API returned invalid {key}");
let text = field(value, key)?;
let digits = |text: &str| -> Option<i32> {
(!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()))
.then(|| text.parse().ok())
@@ -1055,7 +1056,8 @@ fn summary(
draft,
approved: false,
review_requested: false,
created_at: created_at(value)?,
created_at: timestamp(value, "created_at")?,
updated_at: timestamp(value, "updated_at")?,
})
}
+2
View File
@@ -12,6 +12,8 @@ mod lsp_config;
mod markdown;
pub mod panes;
pub mod plugin;
mod pull_request_preferences;
mod pull_requests;
pub mod repository;
pub mod resolve;
pub mod review;
+3 -1
View File
@@ -108,7 +108,9 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
🙏 marks a personal review request; 👍 marks your active approval (API account)
Ctrl+f filters All/Requested/Approved; Ctrl+s sorts Newest/Requested first/Approved first
Ctrl+s sort menu: 1-4 precedence, Backspace clear, Left/Right date direction
Ctrl+f filter menu: Space includes/excludes personal review states; Enter applies, Esc cancels
Applied choices persist; config.pull_request_sort / config.pull_request_filters override at startup
Symbols: config.pull_request_review_requested_symbol / config.pull_request_approved_symbol
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
+151
View File
@@ -0,0 +1,151 @@
use crate::pull_requests::Preferences;
use serde::{Deserialize, Serialize};
use std::{
ffi::OsStr,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredPreferences<T> {
version: u8,
preferences: T,
}
pub(crate) fn default_path() -> Result<PathBuf, String> {
path_from(
std::env::var_os("XDG_STATE_HOME").as_deref(),
std::env::var_os("HOME").as_deref(),
)
}
fn path_from(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf, String> {
if let Some(path) = xdg.map(Path::new).filter(|path| path.is_absolute()) {
return Ok(path.join("vamp/pull_requests.json"));
}
home.map(Path::new)
.filter(|path| path.is_absolute())
.map(|path| path.join(".local/state/vamp/pull_requests.json"))
.ok_or_else(|| {
"Cannot locate pull request preferences: HOME must be an absolute directory".into()
})
}
fn read_preferences(path: &Path) -> Result<Option<Preferences>, String> {
match fs::symlink_metadata(path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.to_string()),
Ok(metadata) if !metadata.is_file() => {
return Err("preferences must be a regular file (not a symlink)".into());
}
Ok(_) => {}
}
let bytes = fs::read(path).map_err(|error| error.to_string())?;
let stored: StoredPreferences<Preferences> =
serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
if stored.version != 1 {
return Err(format!(
"unsupported preferences version {}",
stored.version
));
}
stored.preferences.validate()?;
Ok(Some(stored.preferences))
}
pub(crate) struct Store {
path: Option<PathBuf>,
blocked: Option<String>,
}
impl Store {
pub(crate) fn load(path: Result<PathBuf, String>) -> (Self, Preferences, Option<String>) {
let result = match &path {
Ok(path) => read_preferences(path),
Err(error) => Err(error.clone()),
};
let (preferences, blocked) = match result {
Ok(preferences) => (preferences.unwrap_or_default(), None),
Err(error) => (Preferences::default(), Some(error)),
};
let store = Self {
path: path.ok(),
blocked,
};
let notice = store.blocked.as_ref().map(|error| store.notice(error));
(store, preferences, notice)
}
pub(crate) fn disabled() -> Self {
Self {
path: None,
blocked: None,
}
}
fn notice(&self, error: &str) -> String {
let location = self.path.as_ref().map_or_else(
|| "Pull request preferences".to_owned(),
|path| format!("Pull request preferences ({})", path.display()),
);
format!("{location}: {error}. Changes are session-only; existing data was preserved.")
}
pub(crate) fn save(&self, preferences: &Preferences) -> Result<(), String> {
self.save_inner(preferences)
.map_err(|error| self.notice(&error))
}
fn save_inner(&self, preferences: &Preferences) -> Result<(), String> {
if let Some(error) = &self.blocked {
return Err(error.clone());
}
preferences.validate()?;
let Some(path) = &self.path else {
return Ok(());
};
// Refuse to overwrite data that became corrupt or incompatible after startup.
read_preferences(path)?;
let bytes = serde_json::to_vec_pretty(&StoredPreferences {
version: 1,
preferences,
})
.map_err(|error| error.to_string())?;
let parent = path.parent().ok_or("preferences path has no parent")?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let (temporary, mut file) = loop {
let id = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
let temporary = parent.join(format!(".pull-requests-{}-{id}.tmp", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&temporary) {
Ok(file) => break (temporary, file),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.to_string()),
}
};
let result = (|| {
file.write_all(&bytes).and_then(|_| file.sync_all())?;
drop(file);
read_preferences(path).map_err(io::Error::other)?;
// ponytail: optimistic validation; OS compare-and-swap if concurrent writers matter.
fs::rename(&temporary, path)
})();
let _ = fs::remove_file(temporary);
result.map_err(|error| error.to_string())
}
}
#[cfg(test)]
#[path = "../tests/unit/pull_request_preferences.rs"]
mod tests;
+144
View File
@@ -0,0 +1,144 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct Preferences {
pub sort: SortSettings,
pub filters: Vec<ReviewFilter>,
}
impl Default for Preferences {
fn default() -> Self {
Self {
sort: BTreeMap::new(),
filters: ReviewFilter::ALL.to_vec(),
}
}
}
pub(crate) type SortSettings = BTreeMap<SortField, SortRule>;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SortField {
Requested,
UpdatedAt,
Approved,
CreatedAt,
}
impl SortField {
pub const ALL: [Self; 4] = [
Self::Requested,
Self::UpdatedAt,
Self::Approved,
Self::CreatedAt,
];
pub fn label(self) -> &'static str {
match self {
Self::Requested => "Requested from me",
Self::UpdatedAt => "Updated at",
Self::Approved => "Approved by me",
Self::CreatedAt => "Created at",
}
}
pub fn is_date(self) -> bool {
matches!(self, Self::UpdatedAt | Self::CreatedAt)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct SortRule {
pub precedence: Option<u8>,
pub direction: Direction,
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Direction {
Asc,
#[default]
Desc,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReviewFilter {
Requested,
Approved,
Other,
}
impl ReviewFilter {
pub const ALL: [Self; 3] = [Self::Requested, Self::Approved, Self::Other];
pub fn label(self) -> &'static str {
match self {
Self::Requested => "Requested from me",
Self::Approved => "Approved by me",
Self::Other => "Other / unavailable",
}
}
}
impl Preferences {
pub fn validate(&self) -> Result<(), String> {
let mut ranks = [false; 4];
for rule in self.sort.values() {
if let Some(rank) = rule.precedence {
if !(1..=4).contains(&rank) {
return Err("Sort precedence must be between 1 and 4".into());
}
if std::mem::replace(&mut ranks[usize::from(rank - 1)], true) {
return Err("Sort precedence must be unique".into());
}
}
}
for (index, filter) in self.filters.iter().enumerate() {
if self.filters[..index].contains(filter) {
return Err("Review filters must be unique".into());
}
}
Ok(())
}
pub fn ordered_fields(&self) -> [SortField; 4] {
let mut slots = [None; 4];
for (&field, rule) in &self.sort {
if let Some(rank) = rule.precedence {
slots[usize::from(rank - 1)] = Some(field);
}
}
let mut unranked = SortField::ALL.into_iter().filter(|field| {
self.sort
.get(field)
.is_none_or(|rule| rule.precedence.is_none())
});
slots.map(|field| field.unwrap_or_else(|| unranked.next().expect("four sort fields")))
}
pub fn set_rank(&mut self, field: SortField, rank: Option<u8>) {
self.sort.entry(field).or_default().precedence = None;
let Some(mut rank) = rank else {
return;
};
let mut field = field;
loop {
let displaced = self
.sort
.iter()
.find_map(|(&other, rule)| (rule.precedence == Some(rank)).then_some(other));
self.sort.entry(field).or_default().precedence = Some(rank);
let Some(other) = displaced else {
break;
};
self.sort.get_mut(&other).expect("ranked field").precedence = None;
field = other;
rank = rank % 4 + 1;
}
}
}
+6 -8
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
PullRequestPage, PullRequestReviewState, RepoKind, Revision, RevisionTarget, line_hunks,
PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -1641,13 +1641,11 @@ impl Repository {
Category::Open
},
},
pull.created_at,
if pull.review_requested {
PullRequestReviewState::Requested
} else if pull.approved {
PullRequestReviewState::Approved
} else {
PullRequestReviewState::None
PullRequestMetadata {
created_at: pull.created_at,
updated_at: pull.updated_at,
requested: pull.review_requested,
approved: pull.approved,
},
)
})
+22
View File
@@ -1,6 +1,7 @@
use crate::chrome::{ChromeLine, PopupConfig};
use crate::editor::{Context, Effect, Feature};
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
use crate::pull_requests::{Preferences, ReviewFilter, SortSettings};
use serde::{Deserialize, Serialize};
use std::io::{self, BufRead, BufReader, Write};
use std::num::{NonZeroU8, NonZeroUsize};
@@ -60,6 +61,8 @@ pub struct Config {
pub diff_follow_working_copy: bool,
pub pull_request_review_requested_symbol: String,
pub pull_request_approved_symbol: String,
pub(crate) pull_request_sort: Option<SortSettings>,
pub(crate) pull_request_filters: Option<Vec<ReviewFilter>>,
pub resolve_history_layout: crate::editor::ResolverLayout,
}
@@ -116,6 +119,8 @@ impl Default for Config {
diff_follow_working_copy: true,
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
pull_request_sort: None,
pull_request_filters: None,
resolve_history_layout: crate::editor::ResolverLayout::Columns,
}
}
@@ -159,6 +164,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
diff_follow_working_copy,
pull_request_review_requested_symbol,
pull_request_approved_symbol,
pull_request_sort,
pull_request_filters,
resolve_history_layout,
..
} = *response;
@@ -203,6 +210,17 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.diff_follow_working_copy = diff_follow_working_copy;
config.pull_request_review_requested_symbol = pull_request_review_requested_symbol;
config.pull_request_approved_symbol = pull_request_approved_symbol;
let sort_configured = pull_request_sort.is_some();
let filters_configured = pull_request_filters.is_some();
let preferences = Preferences {
sort: pull_request_sort.unwrap_or_default(),
filters: pull_request_filters.unwrap_or_default(),
};
preferences
.validate()
.map_err(|error| format!("Ruby config: {error}"))?;
config.pull_request_sort = sort_configured.then_some(preferences.sort);
config.pull_request_filters = filters_configured.then_some(preferences.filters);
config.resolve_history_layout = resolve_history_layout;
Ok(config)
}
@@ -568,6 +586,10 @@ struct ConfigFrame {
pull_request_review_requested_symbol: String,
#[serde(deserialize_with = "pull_request_symbol")]
pull_request_approved_symbol: String,
#[serde(default)]
pull_request_sort: Option<SortSettings>,
#[serde(default)]
pull_request_filters: Option<Vec<ReviewFilter>>,
resolve_history_layout: crate::editor::ResolverLayout,
}
+57
View File
@@ -159,6 +159,8 @@ module Vamp
@diff_follow_working_copy = true
@pull_request_review_requested_symbol = "🙏"
@pull_request_approved_symbol = "👍"
@pull_request_sort = nil
@pull_request_filters = nil
@resolve_history_layout = "columns"
end
@@ -341,6 +343,60 @@ module Vamp
@pull_request_approved_symbol = pull_request_symbol!(symbol, "pull_request_approved_symbol")
end
# @param fields [Hash{String, Symbol => Hash}, nil] explicit ranks and sort directions
# @return [Hash, nil]
def pull_request_sort=(fields)
return @pull_request_sort = nil if fields.nil?
raise TypeError, "pull_request_sort must be a Hash or nil" unless fields.is_a?(Hash)
ranks = []
normalized = Vamp.chrome_hash!(fields).to_h do |field, options|
unless %w[requested updated_at approved created_at].include?(field)
raise ArgumentError, "unknown pull_request_sort field: #{field}"
end
raise TypeError, "pull_request_sort #{field} must be a Hash" unless options.is_a?(Hash)
options = Vamp.chrome_hash!(options)
unknown = options.keys - %w[precedence direction]
raise ArgumentError, "unknown pull_request_sort option: #{unknown.first}" unless unknown.empty?
rank = options["precedence"]
unless rank.nil? || (rank.is_a?(Integer) && rank.between?(1, 4))
raise ArgumentError, "pull_request_sort precedence must be between 1 and 4"
end
raise ArgumentError, "duplicate pull_request_sort precedence: #{rank}" if rank && ranks.include?(rank)
ranks << rank if rank
if options.key?("direction")
direction = options["direction"]
direction = direction.to_s if direction.is_a?(Symbol)
unless %w[asc desc].include?(direction)
raise ArgumentError, "pull_request_sort direction must be asc or desc"
end
options["direction"] = direction
end
[field, options]
end
@pull_request_sort = normalized
end
# @param filters [Array<String, Symbol>, nil] selected personal review buckets
# @return [Array<String>, nil]
def pull_request_filters=(filters)
return @pull_request_filters = nil if filters.nil?
raise TypeError, "pull_request_filters must be an Array or nil" unless filters.is_a?(Array)
normalized = filters.map do |filter|
filter = filter.to_s if filter.is_a?(Symbol)
unless %w[requested approved other].include?(filter)
raise ArgumentError, "unknown pull_request_filters value: #{filter.inspect}"
end
filter
end
unless normalized.uniq.length == normalized.length
raise ArgumentError, "duplicate pull_request_filters value"
end
@pull_request_filters = normalized
end
# @param layout [String] "columns" or "base_below"
# @return [String]
def resolve_history_layout=(layout)
@@ -398,6 +454,7 @@ module Vamp
"diff_follow_working_copy" => @diff_follow_working_copy,
"pull_request_review_requested_symbol" => @pull_request_review_requested_symbol,
"pull_request_approved_symbol" => @pull_request_approved_symbol,
"pull_request_sort" => @pull_request_sort, "pull_request_filters" => @pull_request_filters,
"resolve_history_layout" => @resolve_history_layout }
end
+54
View File
@@ -450,6 +450,7 @@ struct ShellCommand {
/// the editor, but cannot independently mutate its plugin registry.
pub struct EditorSession {
editor: Editor,
pull_request_preferences_store: crate::pull_request_preferences::Store,
plugins: BTreeMap<String, RubyPlugin>,
documents: BTreeMap<usize, TrackedDocument>,
requests: BTreeMap<String, AsyncRequests>,
@@ -488,6 +489,20 @@ impl EditorSession {
file: Option<PathBuf>,
capabilities: Vec<String>,
config: Option<&Path>,
) -> io::Result<Self> {
let path = if cfg!(test) {
None
} else {
Some(crate::pull_request_preferences::default_path())
};
Self::open_with_preferences_path(file, capabilities, config, path)
}
pub(crate) fn open_with_preferences_path(
file: Option<PathBuf>,
capabilities: Vec<String>,
config: Option<&Path>,
preferences_path: Option<Result<PathBuf, String>>,
) -> io::Result<Self> {
let mut editor = Editor::open(file, capabilities)?;
let mut notices = Vec::new();
@@ -501,6 +516,28 @@ impl EditorSession {
}),
None => Config::default(),
};
let (pull_request_preferences_store, mut preferences, notice) = match preferences_path {
Some(path) => crate::pull_request_preferences::Store::load(path),
None => (
crate::pull_request_preferences::Store::disabled(),
crate::pull_requests::Preferences::default(),
None,
),
};
if let Some(notice) = notice {
notices.push(notice);
}
editor.pull_request_configured = [
config.pull_request_sort.is_some(),
config.pull_request_filters.is_some(),
];
if let Some(sort) = config.pull_request_sort {
preferences.sort = sort;
}
if let Some(filters) = config.pull_request_filters {
preferences.filters = filters;
}
editor.pull_request_preferences = preferences;
editor.set_review_directory(match config.review_directory {
Some(directory) => directory,
None => crate::review::default_directory().map_err(io::Error::other)?,
@@ -580,6 +617,7 @@ impl EditorSession {
}
let mut session = Self {
editor,
pull_request_preferences_store,
plugins,
documents: BTreeMap::new(),
requests: BTreeMap::new(),
@@ -665,6 +703,7 @@ impl EditorSession {
}
self.start_diff_request();
self.start_resolver_requests();
self.save_pull_request_preferences();
}
pub fn execute(&mut self, command: &str) {
@@ -677,6 +716,20 @@ impl EditorSession {
}
self.start_diff_request();
self.start_resolver_requests();
self.save_pull_request_preferences();
}
fn save_pull_request_preferences(&mut self) -> bool {
if !std::mem::take(&mut self.editor.pull_request_preferences_changed) {
return false;
}
if let Err(error) = self
.pull_request_preferences_store
.save(&self.editor.pull_request_preferences)
{
self.editor.set_message(error);
}
true
}
fn start_resolver_requests(&mut self) {
@@ -1850,6 +1903,7 @@ impl EditorSession {
fn poll_at(&mut self, now: Instant) -> bool {
let mut changed = self.refresh_files_at(now);
changed |= self.save_pull_request_preferences();
changed |= self.poll_gutter_at(now);
changed |= self.synchronize_documents();
changed |= self.poll_battery();
+95 -8
View File
@@ -3367,12 +3367,79 @@ fn draw_diff(
} else {
"After"
};
let title = if pull_requests {
let title = if let Some(menu) = &diff.pull_request_menu {
if menu.sort {
" Sort pull requests ".into()
} else {
" Filter pull requests ".into()
}
} else if pull_requests {
" Pull requests ".into()
} else {
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
};
let content = diff_panel(frame, panel, &title, "", "", true);
if let Some(menu) = &diff.pull_request_menu {
panels.picker_scroll.revisions = None;
let configured = editor
.is_some_and(|editor| editor.pull_request_configured[usize::from(!menu.sort)]);
let mut lines = vec![Line::from("↑/↓ or Ctrl+k/j to select")];
if configured {
lines.push(Line::styled(
"Configured override on next startup",
Style::default().fg(Color::Yellow),
));
}
let count = if menu.sort { 4 } else { 3 };
let fields = menu.draft.ordered_fields();
for index in 0..count {
let text = if menu.sort {
let field = fields[index];
let rule = menu.draft.sort.get(&field).copied().unwrap_or_default();
let rank = rule.precedence.map_or("–".into(), |rank| rank.to_string());
let direction = if !field.is_date() {
""
} else if rule.direction == crate::pull_requests::Direction::Desc {
" · newest first"
} else {
" · oldest first"
};
format!("[{rank}] {}{direction}", field.label())
} else {
let filter = crate::pull_requests::ReviewFilter::ALL[index];
format!(
"[{}] {}",
if menu.draft.filters.contains(&filter) {
"x"
} else {
" "
},
filter.label()
)
};
lines.push(Line::styled(
format!("{} {text}", if index == menu.selected { ">" } else { " " }),
if index == menu.selected {
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
));
}
lines.push(Line::from(if menu.sort {
"1–4 rank · Backspace clear rank"
} else {
"Space toggle · checked buckets OR"
}));
if menu.sort {
lines.push(Line::from("←/→ date direction · unranked default"));
}
lines.push(Line::from("Enter Apply · Esc Cancel · Ctrl+s/f switch"));
frame.render_widget(Paragraph::new(lines), content);
return;
}
let pull_request_notice = (pull_requests && diff.phase == Phase::Before)
.then(|| diff.pull_request_notice())
.flatten();
@@ -3425,14 +3492,34 @@ fn draw_diff(
.as_ref()
.map_or("not selected", |revision| revision.label.as_str());
let stage_text = if pull_requests {
let sort = match diff.pull_request_priority {
crate::diff::PullRequestReviewScope::All => "Newest".into(),
scope => format!("{} first", scope.label()),
let filters = &diff.pull_request_preferences.filters;
let review = if filters.len() == 3 {
"All".into()
} else if filters.is_empty() {
"None".into()
} else {
filters
.iter()
.map(|filter| match filter {
crate::pull_requests::ReviewFilter::Requested => "Requested",
crate::pull_requests::ReviewFilter::Approved => "Approved",
crate::pull_requests::ReviewFilter::Other => "Other",
})
.collect::<Vec<_>>()
.join("+")
};
format!(
"Review: {} · Ctrl+f{}Sort: {sort} · Ctrl+s",
diff.pull_request_filter.label(),
"Review: {review} · Ctrl+f{}Sort settings · Ctrl+s{}",
if stacked_review_controls { "\n" } else { " " },
if editor.is_some_and(|editor| editor
.pull_request_configured
.iter()
.any(|&configured| configured))
{
" · configured"
} else {
""
},
)
} else {
format!(
@@ -3531,8 +3618,8 @@ fn draw_diff(
diff.category,
&diff.query,
diff.comparison_mode as u8,
diff.pull_request_filter,
diff.pull_request_priority,
&diff.pull_request_preferences.filters,
&diff.pull_request_preferences.sort,
diff.catalog
.as_ref()
.map(|catalog| (catalog.entries.as_ptr(), catalog.entries.len())),