Overhaul breadarr-tui UX: filter/sort, color, help overlay, cross-nav
Library tab gets a cycling filter (all/series/movies/missing/unmonitored) and sort (title/missing/kind), color-coded kind tags and missing-count severity, and monitor-toggle without opening detail. Keybinding hints move out of cramped block titles into a context-aware status bar plus a `?` help overlay, both driven by one shared keybinding table so they can't drift apart. Episode status icons and review-queue confidence get the same severity coloring. Stuck tab gains real selection/focus and can jump straight to a show's Library detail (needed adding media_item_id to StalledGrab's query — the one small backend touch in this pass). Adds a manual refresh-now key.
This commit is contained in:
parent
99604a7e55
commit
6e7be67f0b
5 changed files with 534 additions and 74 deletions
|
|
@ -56,6 +56,7 @@ pub struct ReviewQueueEntry {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StalledGrab {
|
pub struct StalledGrab {
|
||||||
pub release_id: i64,
|
pub release_id: i64,
|
||||||
|
pub media_item_id: i64,
|
||||||
pub media_title: String,
|
pub media_title: String,
|
||||||
pub raw_title: String,
|
pub raw_title: String,
|
||||||
pub grabbed_at: String,
|
pub grabbed_at: String,
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,88 @@ impl AddKind {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Client-side filter over the already-fetched `media_items` — the server
|
||||||
|
/// has no filter/sort query params, and the library is small enough that
|
||||||
|
/// refiltering the in-memory `Vec` on every keypress is free.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LibraryFilter {
|
||||||
|
All,
|
||||||
|
Series,
|
||||||
|
Movies,
|
||||||
|
Missing,
|
||||||
|
Unmonitored,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryFilter {
|
||||||
|
pub const ALL: [LibraryFilter; 5] = [
|
||||||
|
LibraryFilter::All,
|
||||||
|
LibraryFilter::Series,
|
||||||
|
LibraryFilter::Movies,
|
||||||
|
LibraryFilter::Missing,
|
||||||
|
LibraryFilter::Unmonitored,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn next(self) -> Self {
|
||||||
|
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
|
||||||
|
Self::ALL[(idx + 1) % Self::ALL.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LibraryFilter::All => "all",
|
||||||
|
LibraryFilter::Series => "series",
|
||||||
|
LibraryFilter::Movies => "movies",
|
||||||
|
LibraryFilter::Missing => "missing",
|
||||||
|
LibraryFilter::Unmonitored => "unmonitored",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, m: &MediaItemSummary) -> bool {
|
||||||
|
match self {
|
||||||
|
LibraryFilter::All => true,
|
||||||
|
LibraryFilter::Series => m.kind == "series",
|
||||||
|
LibraryFilter::Movies => m.kind == "movie",
|
||||||
|
LibraryFilter::Missing => m.missing_count > 0,
|
||||||
|
LibraryFilter::Unmonitored => !m.monitored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LibrarySort {
|
||||||
|
TitleAsc,
|
||||||
|
MissingDesc,
|
||||||
|
Kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibrarySort {
|
||||||
|
pub const ALL: [LibrarySort; 3] = [
|
||||||
|
LibrarySort::TitleAsc,
|
||||||
|
LibrarySort::MissingDesc,
|
||||||
|
LibrarySort::Kind,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn next(self) -> Self {
|
||||||
|
let idx = Self::ALL.iter().position(|s| *s == self).unwrap_or(0);
|
||||||
|
Self::ALL[(idx + 1) % Self::ALL.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LibrarySort::TitleAsc => "title",
|
||||||
|
LibrarySort::MissingDesc => "missing",
|
||||||
|
LibrarySort::Kind => "kind",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which half of the Stuck tab has selection/arrow-key focus.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StuckSection {
|
||||||
|
Stalled,
|
||||||
|
Maxed,
|
||||||
|
}
|
||||||
|
|
||||||
/// A weight axis's display name plus a getter/setter pair, so
|
/// A weight axis's display name plus a getter/setter pair, so
|
||||||
/// `WEIGHT_FIELDS` can enumerate `WeightsDto`'s fields by position instead
|
/// `WEIGHT_FIELDS` can enumerate `WeightsDto`'s fields by position instead
|
||||||
/// of every caller matching on an index.
|
/// of every caller matching on an index.
|
||||||
|
|
@ -123,9 +205,24 @@ pub struct App {
|
||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
|
/// Toggled by `?`; intercepted at the top of `main.rs`'s key handler
|
||||||
|
/// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its
|
||||||
|
/// own bool (not folded into `Focus`) since it needs to open from any
|
||||||
|
/// tab rather than being scoped to one.
|
||||||
|
pub help_visible: bool,
|
||||||
|
/// Set by the `R` key; consumed by `main.rs`'s refresh loop to bypass
|
||||||
|
/// the normal 3s throttle for one immediate refresh.
|
||||||
|
pub force_refresh: bool,
|
||||||
|
|
||||||
pub media_items: Vec<MediaItemSummary>,
|
pub media_items: Vec<MediaItemSummary>,
|
||||||
pub media_state: ListState,
|
pub media_state: ListState,
|
||||||
|
/// Filtered+sorted indices into `media_items`, recomputed by
|
||||||
|
/// `recompute_library_view` — the top-level Library list and
|
||||||
|
/// `media_state` always operate over this, never over `media_items`
|
||||||
|
/// directly, so filter/sort never has to touch the raw fetched data.
|
||||||
|
pub library_view: Vec<usize>,
|
||||||
|
pub library_filter: LibraryFilter,
|
||||||
|
pub library_sort: LibrarySort,
|
||||||
pub detail: Option<MediaItemDetail>,
|
pub detail: Option<MediaItemDetail>,
|
||||||
/// Selection within `detail.episodes` — separate from `media_state`
|
/// Selection within `detail.episodes` — separate from `media_state`
|
||||||
/// since they're two different lists sharing the same tab.
|
/// since they're two different lists sharing the same tab.
|
||||||
|
|
@ -142,6 +239,9 @@ pub struct App {
|
||||||
pub add_results_state: ListState,
|
pub add_results_state: ListState,
|
||||||
|
|
||||||
pub stuck: Option<StuckReport>,
|
pub stuck: Option<StuckReport>,
|
||||||
|
pub stuck_focus: StuckSection,
|
||||||
|
pub stalled_state: ListState,
|
||||||
|
pub maxed_state: ListState,
|
||||||
pub calendar: Vec<CalendarEntry>,
|
pub calendar: Vec<CalendarEntry>,
|
||||||
pub library_health: Option<LibraryHealthReport>,
|
pub library_health: Option<LibraryHealthReport>,
|
||||||
|
|
||||||
|
|
@ -185,8 +285,13 @@ impl App {
|
||||||
focus: Focus::List,
|
focus: Focus::List,
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
|
help_visible: false,
|
||||||
|
force_refresh: false,
|
||||||
media_items: Vec::new(),
|
media_items: Vec::new(),
|
||||||
media_state: ListState::default(),
|
media_state: ListState::default(),
|
||||||
|
library_view: Vec::new(),
|
||||||
|
library_filter: LibraryFilter::All,
|
||||||
|
library_sort: LibrarySort::TitleAsc,
|
||||||
detail: None,
|
detail: None,
|
||||||
episode_state: ListState::default(),
|
episode_state: ListState::default(),
|
||||||
releases: Vec::new(),
|
releases: Vec::new(),
|
||||||
|
|
@ -197,6 +302,9 @@ impl App {
|
||||||
add_results: Vec::new(),
|
add_results: Vec::new(),
|
||||||
add_results_state: ListState::default(),
|
add_results_state: ListState::default(),
|
||||||
stuck: None,
|
stuck: None,
|
||||||
|
stuck_focus: StuckSection::Stalled,
|
||||||
|
stalled_state: ListState::default(),
|
||||||
|
maxed_state: ListState::default(),
|
||||||
calendar: Vec::new(),
|
calendar: Vec::new(),
|
||||||
library_health: None,
|
library_health: None,
|
||||||
candidates: Vec::new(),
|
candidates: Vec::new(),
|
||||||
|
|
@ -236,9 +344,7 @@ impl App {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.media_items = self.client.list_media().await?;
|
self.media_items = self.client.list_media().await?;
|
||||||
if self.media_state.selected().is_none() && !self.media_items.is_empty() {
|
self.recompute_library_view();
|
||||||
self.media_state.select(Some(0));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tab::History => {
|
Tab::History => {
|
||||||
|
|
@ -255,7 +361,17 @@ impl App {
|
||||||
}
|
}
|
||||||
Tab::Add => {}
|
Tab::Add => {}
|
||||||
Tab::Stuck => {
|
Tab::Stuck => {
|
||||||
self.stuck = Some(self.client.stuck().await?);
|
let report = self.client.stuck().await?;
|
||||||
|
if self.stalled_state.selected().is_none() && !report.stalled_grabs.is_empty()
|
||||||
|
{
|
||||||
|
self.stalled_state.select(Some(0));
|
||||||
|
}
|
||||||
|
if self.maxed_state.selected().is_none()
|
||||||
|
&& !report.maxed_out_search_targets.is_empty()
|
||||||
|
{
|
||||||
|
self.maxed_state.select(Some(0));
|
||||||
|
}
|
||||||
|
self.stuck = Some(report);
|
||||||
}
|
}
|
||||||
Tab::Calendar => {
|
Tab::Calendar => {
|
||||||
self.calendar = self.client.calendar().await?;
|
self.calendar = self.client.calendar().await?;
|
||||||
|
|
@ -287,13 +403,68 @@ impl App {
|
||||||
self.detail.as_ref().map(|d| d.id)
|
self.detail.as_ref().map(|d| d.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebuilds `library_view` from `media_items` under the current
|
||||||
|
/// filter/sort, then re-selects whichever item was selected before (by
|
||||||
|
/// id, not raw index) if it's still in view — falls back to the first
|
||||||
|
/// item, or no selection if the view is now empty. Called after
|
||||||
|
/// `media_items` changes, and after `library_filter`/`library_sort`
|
||||||
|
/// change.
|
||||||
|
pub fn recompute_library_view(&mut self) {
|
||||||
|
let previously_selected_id = self.selected_media_item().map(|m| m.id);
|
||||||
|
|
||||||
|
let mut indices: Vec<usize> = self
|
||||||
|
.media_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, m)| self.library_filter.matches(m))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match self.library_sort {
|
||||||
|
LibrarySort::TitleAsc => indices.sort_by(|&a, &b| {
|
||||||
|
self.media_items[a]
|
||||||
|
.title
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&self.media_items[b].title.to_lowercase())
|
||||||
|
}),
|
||||||
|
LibrarySort::MissingDesc => indices.sort_by(|&a, &b| {
|
||||||
|
self.media_items[b]
|
||||||
|
.missing_count
|
||||||
|
.cmp(&self.media_items[a].missing_count)
|
||||||
|
}),
|
||||||
|
LibrarySort::Kind => indices.sort_by(|&a, &b| {
|
||||||
|
self.media_items[a].kind.cmp(&self.media_items[b].kind).then_with(|| {
|
||||||
|
self.media_items[a]
|
||||||
|
.title
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&self.media_items[b].title.to_lowercase())
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
self.library_view = indices;
|
||||||
|
|
||||||
|
match previously_selected_id
|
||||||
|
.and_then(|id| self.library_view.iter().position(|&i| self.media_items[i].id == id))
|
||||||
|
{
|
||||||
|
Some(pos) => self.media_state.select(Some(pos)),
|
||||||
|
None if !self.library_view.is_empty() => self.media_state.select(Some(0)),
|
||||||
|
None => self.media_state.select(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_media_item(&self) -> Option<&MediaItemSummary> {
|
||||||
|
let idx = self.media_state.selected()?;
|
||||||
|
let real_idx = *self.library_view.get(idx)?;
|
||||||
|
self.media_items.get(real_idx)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn move_selection(&mut self, delta: i32) {
|
pub fn move_selection(&mut self, delta: i32) {
|
||||||
let (state, len) = match self.tab {
|
let (state, len) = match self.tab {
|
||||||
Tab::Library if matches!(self.focus, Focus::Candidates) => {
|
Tab::Library if matches!(self.focus, Focus::Candidates) => {
|
||||||
(&mut self.candidates_state, self.candidates.len())
|
(&mut self.candidates_state, self.candidates.len())
|
||||||
}
|
}
|
||||||
Tab::Library if self.detail.is_none() => {
|
Tab::Library if self.detail.is_none() => {
|
||||||
(&mut self.media_state, self.media_items.len())
|
(&mut self.media_state, self.library_view.len())
|
||||||
}
|
}
|
||||||
Tab::Library => (
|
Tab::Library => (
|
||||||
&mut self.episode_state,
|
&mut self.episode_state,
|
||||||
|
|
@ -302,6 +473,15 @@ impl App {
|
||||||
Tab::History => (&mut self.releases_state, self.releases.len()),
|
Tab::History => (&mut self.releases_state, self.releases.len()),
|
||||||
Tab::Review => (&mut self.review_state, self.review_items.len()),
|
Tab::Review => (&mut self.review_state, self.review_items.len()),
|
||||||
Tab::Add => (&mut self.add_results_state, self.add_results.len()),
|
Tab::Add => (&mut self.add_results_state, self.add_results.len()),
|
||||||
|
Tab::Stuck => {
|
||||||
|
let report_len = self.stuck.as_ref().map_or((0, 0), |r| {
|
||||||
|
(r.stalled_grabs.len(), r.maxed_out_search_targets.len())
|
||||||
|
});
|
||||||
|
match self.stuck_focus {
|
||||||
|
StuckSection::Stalled => (&mut self.stalled_state, report_len.0),
|
||||||
|
StuckSection::Maxed => (&mut self.maxed_state, report_len.1),
|
||||||
|
}
|
||||||
|
}
|
||||||
Tab::Profiles if self.profile_detail.is_some() => {
|
Tab::Profiles if self.profile_detail.is_some() => {
|
||||||
(&mut self.profile_weight_state, WEIGHT_FIELDS.len())
|
(&mut self.profile_weight_state, WEIGHT_FIELDS.len())
|
||||||
}
|
}
|
||||||
|
|
@ -320,10 +500,7 @@ impl App {
|
||||||
if !matches!(self.tab, Tab::Library) || self.detail.is_some() {
|
if !matches!(self.tab, Tab::Library) || self.detail.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(idx) = self.media_state.selected() else {
|
let Some(item) = self.selected_media_item() else {
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(item) = self.media_items.get(idx) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match self.client.media_detail(item.id).await {
|
match self.client.media_detail(item.id).await {
|
||||||
|
|
@ -707,6 +884,28 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggles monitored for whatever's selected in the top-level Library
|
||||||
|
/// list, without needing to open its detail view first.
|
||||||
|
pub async fn toggle_monitor_list_selected(&mut self) {
|
||||||
|
let Some(item) = self.selected_media_item() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (id, monitored) = (item.id, item.monitored);
|
||||||
|
let result = if monitored {
|
||||||
|
self.client.unmonitor(id).await
|
||||||
|
} else {
|
||||||
|
self.client.monitor(id).await
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.status = "monitor state updated".to_string();
|
||||||
|
self.media_items = self.client.list_media().await.unwrap_or_default();
|
||||||
|
self.recompute_library_view();
|
||||||
|
}
|
||||||
|
Err(e) => self.status = format!("monitor toggle failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn toggle_monitor_selected(&mut self) {
|
pub async fn toggle_monitor_selected(&mut self) {
|
||||||
let Some(detail) = &self.detail else {
|
let Some(detail) = &self.detail else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -745,6 +944,7 @@ impl App {
|
||||||
self.status = "deleted".to_string();
|
self.status = "deleted".to_string();
|
||||||
self.detail = None;
|
self.detail = None;
|
||||||
self.media_items = self.client.list_media().await.unwrap_or_default();
|
self.media_items = self.client.list_media().await.unwrap_or_default();
|
||||||
|
self.recompute_library_view();
|
||||||
}
|
}
|
||||||
Err(e) => self.status = format!("delete failed: {e}"),
|
Err(e) => self.status = format!("delete failed: {e}"),
|
||||||
}
|
}
|
||||||
|
|
@ -789,4 +989,40 @@ impl App {
|
||||||
Err(e) => self.status = format!("file delete failed: {e}"),
|
Err(e) => self.status = format!("file delete failed: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jumps from whichever Stuck-tab row is selected straight to that
|
||||||
|
/// show's Library detail view.
|
||||||
|
pub async fn jump_to_stuck_target(&mut self) {
|
||||||
|
let Some(report) = &self.stuck else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let media_item_id = match self.stuck_focus {
|
||||||
|
StuckSection::Maxed => self
|
||||||
|
.maxed_state
|
||||||
|
.selected()
|
||||||
|
.and_then(|i| report.maxed_out_search_targets.get(i))
|
||||||
|
.map(|t| t.media_item_id),
|
||||||
|
StuckSection::Stalled => self
|
||||||
|
.stalled_state
|
||||||
|
.selected()
|
||||||
|
.and_then(|i| report.stalled_grabs.get(i))
|
||||||
|
.map(|g| g.media_item_id),
|
||||||
|
};
|
||||||
|
let Some(id) = media_item_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match self.client.media_detail(id).await {
|
||||||
|
Ok(detail) => {
|
||||||
|
self.tab = Tab::Library;
|
||||||
|
self.episode_state.select(if detail.episodes.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
});
|
||||||
|
self.detail = Some(detail);
|
||||||
|
self.focus = Focus::List;
|
||||||
|
}
|
||||||
|
Err(e) => self.status = format!("error loading detail: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use crossterm::terminal::{
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
||||||
use app::{App, Focus, Tab};
|
use app::{App, Focus, StuckSection, Tab};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
|
@ -47,8 +47,9 @@ async fn run(
|
||||||
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
|
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if last_refresh.elapsed() >= Duration::from_secs(3) {
|
if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh {
|
||||||
app.refresh_active_tab().await;
|
app.refresh_active_tab().await;
|
||||||
|
app.force_refresh = false;
|
||||||
last_refresh = tokio::time::Instant::now();
|
last_refresh = tokio::time::Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +102,16 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Help overlay intercepts everything while open, same
|
||||||
|
// priority-over-global-keys idiom as the two input modes above.
|
||||||
|
if app.help_visible {
|
||||||
|
match code {
|
||||||
|
KeyCode::Char('?') | KeyCode::Esc => app.help_visible = false,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Any key other than a second `x`/`d` clears a pending delete
|
// Any key other than a second `x`/`d` clears a pending delete
|
||||||
// confirmation — the confirmation must be the very next keypress, not
|
// confirmation — the confirmation must be the very next keypress, not
|
||||||
// just "any keypress before the user gets distracted."
|
// just "any keypress before the user gets distracted."
|
||||||
|
|
@ -136,8 +147,28 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
|
||||||
app.start_editing_selected_weight();
|
app.start_editing_selected_weight();
|
||||||
}
|
}
|
||||||
Tab::Profiles => app.open_profile_detail(),
|
Tab::Profiles => app.open_profile_detail(),
|
||||||
|
Tab::Stuck => app.jump_to_stuck_target().await,
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => {
|
||||||
|
app.stuck_focus = match app.stuck_focus {
|
||||||
|
StuckSection::Stalled => StuckSection::Maxed,
|
||||||
|
StuckSection::Maxed => StuckSection::Stalled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Char('?') => app.help_visible = true,
|
||||||
|
KeyCode::Char('R') => app.force_refresh = true,
|
||||||
|
KeyCode::Char('f') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
|
||||||
|
app.library_filter = app.library_filter.next();
|
||||||
|
app.recompute_library_view();
|
||||||
|
}
|
||||||
|
KeyCode::Char('o') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
|
||||||
|
app.library_sort = app.library_sort.next();
|
||||||
|
app.recompute_library_view();
|
||||||
|
}
|
||||||
|
KeyCode::Char('m') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
|
||||||
|
app.toggle_monitor_list_selected().await;
|
||||||
|
}
|
||||||
KeyCode::Char('a') if matches!(app.tab, Tab::Review) => {
|
KeyCode::Char('a') if matches!(app.tab, Tab::Review) => {
|
||||||
app.approve_selected_review().await;
|
app.approve_selected_review().await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, Focus, Tab};
|
use crate::app::{App, Focus, StuckSection, Tab};
|
||||||
|
|
||||||
|
// Shared color palette — kept to these meanings so a color never has to be
|
||||||
|
// second-guessed at a glance:
|
||||||
|
// Green healthy / 0 missing / high confidence / not stuck
|
||||||
|
// Yellow warning / low missing / mid confidence / at backoff ceiling
|
||||||
|
// Red critical / high missing / low confidence / past ceiling
|
||||||
|
// DarkGray unmonitored / muted / not currently relevant
|
||||||
|
// Blue TV kind tag (categorical, not severity)
|
||||||
|
// Magenta Movie kind tag (categorical, not severity)
|
||||||
|
// Cyan focus/interactive accent (tab highlight, active input, focused section) — never severity
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, app: &App) {
|
pub fn draw(frame: &mut Frame, app: &App) {
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
|
|
@ -30,6 +40,102 @@ pub fn draw(frame: &mut Frame, app: &App) {
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_status(frame, chunks[2], app);
|
draw_status(frame, chunks[2], app);
|
||||||
|
|
||||||
|
if app.help_visible {
|
||||||
|
draw_help_overlay(frame, frame.area(), app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keybindings relevant to the current tab/focus/detail state, in the order
|
||||||
|
/// they should be shown — the single source of truth shared by the status
|
||||||
|
/// bar (which shows a short prefix) and the help overlay (which shows all of
|
||||||
|
/// it plus `GLOBAL_KEYS`), so the two can't drift apart.
|
||||||
|
fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||||
|
match app.tab {
|
||||||
|
Tab::Library if matches!(app.focus, Focus::Candidates) => {
|
||||||
|
vec![("Enter", "grab"), ("Esc", "cancel")]
|
||||||
|
}
|
||||||
|
Tab::Library if app.detail.is_some() => vec![
|
||||||
|
("Esc", "back"),
|
||||||
|
("s", "search now"),
|
||||||
|
("m", "monitor show"),
|
||||||
|
("e", "monitor episode"),
|
||||||
|
("S", "monitor season"),
|
||||||
|
("x", "delete show"),
|
||||||
|
("d", "delete file"),
|
||||||
|
("c", "pick release"),
|
||||||
|
],
|
||||||
|
Tab::Library => vec![
|
||||||
|
("Enter", "open detail"),
|
||||||
|
("f", "cycle filter"),
|
||||||
|
("o", "cycle sort"),
|
||||||
|
("m", "monitor toggle"),
|
||||||
|
],
|
||||||
|
Tab::Review => vec![("a", "approve"), ("r", "reject")],
|
||||||
|
Tab::Add => match app.focus {
|
||||||
|
Focus::AddSearchInput => vec![("Enter", "search")],
|
||||||
|
_ => vec![("Enter", "add"), ("Esc", "back to search")],
|
||||||
|
},
|
||||||
|
Tab::Profiles if app.profile_detail.is_some() => {
|
||||||
|
vec![("Enter", "edit weight"), ("Esc", "back")]
|
||||||
|
}
|
||||||
|
Tab::Profiles => vec![("Enter", "open profile")],
|
||||||
|
Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_KEYS: &[(&str, &str)] = &[
|
||||||
|
("Tab", "switch tab"),
|
||||||
|
("j/k", "move"),
|
||||||
|
("?", "help"),
|
||||||
|
("R", "refresh now"),
|
||||||
|
("q", "quit"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Centers a `width` x `height` rect inside `area` — standard ratatui idiom
|
||||||
|
/// for a popup/overlay.
|
||||||
|
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||||
|
let width = width.min(area.width);
|
||||||
|
let height = height.min(area.height);
|
||||||
|
Rect {
|
||||||
|
x: area.x + (area.width.saturating_sub(width)) / 2,
|
||||||
|
y: area.y + (area.height.saturating_sub(height)) / 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let mut lines: Vec<Line> = context_keybindings(app)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, desc)| {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(desc),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Global",
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
lines.extend(GLOBAL_KEYS.iter().map(|(key, desc)| {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(*desc),
|
||||||
|
])
|
||||||
|
}));
|
||||||
|
|
||||||
|
let popup = centered_rect(50, lines.len() as u16 + 2, area);
|
||||||
|
frame.render_widget(Clear, popup);
|
||||||
|
let paragraph = Paragraph::new(lines).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title("Help — ? or Esc to close"),
|
||||||
|
);
|
||||||
|
frame.render_widget(paragraph, popup);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
|
@ -88,18 +194,21 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
.episodes
|
.episodes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
let status = if e.has_file {
|
let (status, color) = if e.has_file {
|
||||||
"✓"
|
("✓", Color::Green)
|
||||||
} else if e.monitored {
|
} else if e.monitored {
|
||||||
"…"
|
("…", Color::Yellow)
|
||||||
} else {
|
} else {
|
||||||
"-"
|
("-", Color::DarkGray)
|
||||||
};
|
};
|
||||||
let title = e.title.as_deref().unwrap_or("");
|
let title = e.title.as_deref().unwrap_or("");
|
||||||
ListItem::new(format!(
|
ListItem::new(Line::from(vec![
|
||||||
"{status} S{:02}E{:02} {title}",
|
Span::styled(status, Style::default().fg(color)),
|
||||||
|
Span::raw(format!(
|
||||||
|
" S{:02}E{:02} {title}",
|
||||||
e.season_number, e.episode_number
|
e.season_number, e.episode_number
|
||||||
))
|
)),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let monitor_label = if detail.monitored {
|
let monitor_label = if detail.monitored {
|
||||||
|
|
@ -116,9 +225,7 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
};
|
};
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||||
"{} ({}) [{monitor_label}] — Esc: back s: search now m: monitor show \
|
"{} ({}) [{monitor_label}]{confirm}",
|
||||||
e: monitor episode S: monitor season x: delete show d: delete file \
|
|
||||||
c: pick release{confirm}",
|
|
||||||
detail.title,
|
detail.title,
|
||||||
detail.year.map(|y| y.to_string()).unwrap_or_default()
|
detail.year.map(|y| y.to_string()).unwrap_or_default()
|
||||||
)))
|
)))
|
||||||
|
|
@ -129,28 +236,64 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.media_items
|
.library_view
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|&i| {
|
||||||
let missing = if m.missing_count > 0 {
|
let m = &app.media_items[i];
|
||||||
|
let kind_tag = if m.kind == "movie" { "[Movie]" } else { "[TV]" };
|
||||||
|
let kind_color = if m.kind == "movie" {
|
||||||
|
Color::Magenta
|
||||||
|
} else {
|
||||||
|
Color::Blue
|
||||||
|
};
|
||||||
|
let ratio = if m.episode_count > 0 {
|
||||||
|
m.missing_count as f64 / m.episode_count as f64
|
||||||
|
} else if m.missing_count > 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
// Mute severity color for unmonitored items — a missing count
|
||||||
|
// on something deliberately unmonitored isn't actionable.
|
||||||
|
let missing_color = if !m.monitored || m.missing_count == 0 {
|
||||||
|
Color::DarkGray
|
||||||
|
} else if ratio <= 0.25 {
|
||||||
|
Color::Yellow
|
||||||
|
} else {
|
||||||
|
Color::Red
|
||||||
|
};
|
||||||
|
let missing_text = if m.missing_count > 0 {
|
||||||
format!(" — {} missing", m.missing_count)
|
format!(" — {} missing", m.missing_count)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
ListItem::new(format!(
|
let title_style = if m.monitored {
|
||||||
"{} ({}){}",
|
Style::default()
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(format!("{kind_tag} "), Style::default().fg(kind_color)),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{} ({})",
|
||||||
m.title,
|
m.title,
|
||||||
m.year.map(|y| y.to_string()).unwrap_or_default(),
|
m.year.map(|y| y.to_string()).unwrap_or_default()
|
||||||
missing
|
),
|
||||||
))
|
title_style,
|
||||||
|
),
|
||||||
|
Span::styled(missing_text, Style::default().fg(missing_color)),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(
|
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||||
Block::default()
|
"Monitored Shows ({}/{}) — filter: {} sort: {}",
|
||||||
.borders(Borders::ALL)
|
app.library_view.len(),
|
||||||
.title("Monitored Shows — Enter for detail"),
|
app.media_items.len(),
|
||||||
)
|
app.library_filter.label(),
|
||||||
|
app.library_sort.label()
|
||||||
|
)))
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
let mut state = app.media_state.clone();
|
let mut state = app.media_state.clone();
|
||||||
frame.render_stateful_widget(list, area, &mut state);
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
|
@ -233,12 +376,21 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
.review_items
|
.review_items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
ListItem::new(format!(
|
let color = if r.confidence < 0.70 {
|
||||||
"({:.0}%) {} -> {}",
|
Color::Red
|
||||||
r.confidence * 100.0,
|
} else if r.confidence < 0.85 {
|
||||||
|
Color::Yellow
|
||||||
|
} else {
|
||||||
|
Color::Green
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)),
|
||||||
|
Span::raw(format!(
|
||||||
|
" {} -> {}",
|
||||||
r.raw_release_title,
|
r.raw_release_title,
|
||||||
r.candidate_media_title.as_deref().unwrap_or("?")
|
r.candidate_media_title.as_deref().unwrap_or("?")
|
||||||
))
|
)),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
|
|
@ -256,6 +408,11 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
/// than expected, how deep the review queue has backed up, and search
|
/// than expected, how deep the review queue has backed up, and search
|
||||||
/// targets that have been failing every attempt long enough for their
|
/// targets that have been failing every attempt long enough for their
|
||||||
/// backoff to hit its ceiling. Read-only report, no selection/navigation.
|
/// backoff to hit its ceiling. Read-only report, no selection/navigation.
|
||||||
|
// Mirrors breadarrd/src/api/routes/stuck.rs::MAXED_SEARCH_COUNT. Duplicated
|
||||||
|
// because the daemon doesn't expose it via the API; if it drifts this is
|
||||||
|
// cosmetic only (wrong shade), not a behavior bug.
|
||||||
|
const MAXED_SEARCH_COUNT: i64 = 6;
|
||||||
|
|
||||||
fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let Some(report) = &app.stuck else {
|
let Some(report) = &app.stuck else {
|
||||||
let placeholder = Paragraph::new("loading...").block(
|
let placeholder = Paragraph::new("loading...").block(
|
||||||
|
|
@ -272,42 +429,71 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
.constraints([Constraint::Min(3), Constraint::Min(3)])
|
.constraints([Constraint::Min(3), Constraint::Min(3)])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
|
let stalled_border = if matches!(app.stuck_focus, StuckSection::Stalled) {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::Reset
|
||||||
|
};
|
||||||
let stalled_items: Vec<ListItem> = report
|
let stalled_items: Vec<ListItem> = report
|
||||||
.stalled_grabs
|
.stalled_grabs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|g| {
|
.map(|g| {
|
||||||
ListItem::new(format!(
|
ListItem::new(Line::from(Span::styled(
|
||||||
"{} — {} (grabbed {})",
|
format!("{} — {} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at),
|
||||||
g.media_title, g.raw_title, g.grabbed_at
|
Style::default().fg(Color::Yellow),
|
||||||
))
|
)))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let stalled_list =
|
let stalled_list = List::new(stalled_items)
|
||||||
List::new(stalled_items).block(Block::default().borders(Borders::ALL).title(format!(
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(stalled_border))
|
||||||
|
.title(format!(
|
||||||
"Stalled grabs ({}) — review queue: {} pending",
|
"Stalled grabs ({}) — review queue: {} pending",
|
||||||
report.stalled_grabs.len(),
|
report.stalled_grabs.len(),
|
||||||
report.review_queue_depth
|
report.review_queue_depth
|
||||||
)));
|
)),
|
||||||
frame.render_widget(stalled_list, chunks[0]);
|
)
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
let mut stalled_state = app.stalled_state.clone();
|
||||||
|
frame.render_stateful_widget(stalled_list, chunks[0], &mut stalled_state);
|
||||||
|
|
||||||
|
let maxed_border = if matches!(app.stuck_focus, StuckSection::Maxed) {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::Reset
|
||||||
|
};
|
||||||
let maxed_items: Vec<ListItem> = report
|
let maxed_items: Vec<ListItem> = report
|
||||||
.maxed_out_search_targets
|
.maxed_out_search_targets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
ListItem::new(format!(
|
let color = if t.search_count > MAXED_SEARCH_COUNT {
|
||||||
|
Color::Red
|
||||||
|
} else {
|
||||||
|
Color::Yellow
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(Span::styled(
|
||||||
|
format!(
|
||||||
"{} — {} attempts, last searched {}",
|
"{} — {} attempts, last searched {}",
|
||||||
t.media_title,
|
t.media_title,
|
||||||
t.search_count,
|
t.search_count,
|
||||||
t.last_searched_at.as_deref().unwrap_or("never")
|
t.last_searched_at.as_deref().unwrap_or("never")
|
||||||
))
|
),
|
||||||
|
Style::default().fg(color),
|
||||||
|
)))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let maxed_list = List::new(maxed_items).block(
|
let maxed_list = List::new(maxed_items)
|
||||||
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title("Search targets at max backoff"),
|
.border_style(Style::default().fg(maxed_border))
|
||||||
);
|
.title("Search targets at max backoff — Enter: jump to show"),
|
||||||
frame.render_widget(maxed_list, chunks[1]);
|
)
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
let mut maxed_state = app.maxed_state.clone();
|
||||||
|
frame.render_stateful_widget(maxed_list, chunks[1], &mut maxed_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
|
@ -549,10 +735,15 @@ fn draw_profiles(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let text = if app.status.is_empty() {
|
let text = if !app.status.is_empty() {
|
||||||
"Tab: switch view | j/k: move | q: quit".to_string()
|
|
||||||
} else {
|
|
||||||
app.status.clone()
|
app.status.clone()
|
||||||
|
} else {
|
||||||
|
let hints: Vec<String> = context_keybindings(app)
|
||||||
|
.into_iter()
|
||||||
|
.take(4)
|
||||||
|
.map(|(key, desc)| format!("{key}: {desc}"))
|
||||||
|
.collect();
|
||||||
|
format!("{} | ?: help", hints.join(" | "))
|
||||||
};
|
};
|
||||||
let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
|
let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
|
||||||
frame.render_widget(status, area);
|
frame.render_widget(status, area);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ pub async fn stuck(
|
||||||
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT r.id, m.title, r.raw_title, r.grabbed_at
|
"SELECT r.id, r.media_item_id, m.title, r.raw_title, r.grabbed_at
|
||||||
FROM release r JOIN media_item m ON m.id = r.media_item_id
|
FROM release r JOIN media_item m ON m.id = r.media_item_id
|
||||||
WHERE r.status = 'grabbed'
|
WHERE r.status = 'grabbed'
|
||||||
AND (julianday('now') - julianday(r.grabbed_at)) * 24.0 > ?1
|
AND (julianday('now') - julianday(r.grabbed_at)) * 24.0 > ?1
|
||||||
|
|
@ -34,9 +34,10 @@ pub async fn stuck(
|
||||||
.query_map([STALLED_GRAB_HOURS], |row| {
|
.query_map([STALLED_GRAB_HOURS], |row| {
|
||||||
Ok(StalledGrab {
|
Ok(StalledGrab {
|
||||||
release_id: row.get(0)?,
|
release_id: row.get(0)?,
|
||||||
media_title: row.get(1)?,
|
media_item_id: row.get(1)?,
|
||||||
raw_title: row.get(2)?,
|
media_title: row.get(2)?,
|
||||||
grabbed_at: row.get(3)?,
|
raw_title: row.get(3)?,
|
||||||
|
grabbed_at: row.get(4)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(internal)?
|
.map_err(internal)?
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue