feat: add persistent pull request sort and filter menus
This commit is contained in:
+187
-95
@@ -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
@@ -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
@@ -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")?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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())),
|
||||
|
||||
Reference in New Issue
Block a user