diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index c53349a..6a43146 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -103,12 +103,31 @@ pub struct Item { pub key: String, pub title: String, pub graph_lane: Option, - pub activity_kind: Option, + pub decoration: ItemDecoration, pub meta: String, pub detail: String, pub target: Target, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WorkItemState { + Open, + Closed, + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ItemDecoration { + None, + Activity(ActivityKind), + State(WorkItemState), + Milestone { + state: WorkItemState, + closed: i64, + total: i64, + }, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ActivityKind { Repository, @@ -1167,7 +1186,7 @@ impl App { name.clone() }, graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: format!("{} · {}", server.provider, server.url), detail: format!( "Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••", @@ -1245,7 +1264,7 @@ impl App { ), title, graph_lane: None, - activity_kind: Some(activity_kind), + decoration: ItemDecoration::Activity(activity_kind), meta: activity.created.unwrap_or_default(), detail, target, @@ -1343,7 +1362,7 @@ impl App { key: format!("{}/{}", id.owner, id.repository), title: format!("{} {name}", if favorite { "★" } else { " " }), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: format!( "{} · {} open · {}", repository.language.as_deref().unwrap_or(""), @@ -1444,7 +1463,7 @@ impl App { .unwrap_or("unknown") ), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: comment.updated_at.unwrap_or_default(), detail: body.clone(), target: id.map_or(Target::None, |id| { @@ -1557,7 +1576,7 @@ impl App { key: path.clone(), title: format!("{} {name}", if directory { "▸" } else { " " }), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: if directory { "directory".into() } else { @@ -1662,7 +1681,7 @@ impl App { key: path.clone(), title: path.clone(), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: file.status.unwrap_or_else(|| "modified".into()), detail: format!( "+{} -{}", @@ -1688,7 +1707,7 @@ impl App { .unwrap_or("unknown") ), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: comment.updated_at.unwrap_or_default(), detail: comment.body.unwrap_or_default(), target: Target::None, @@ -1699,7 +1718,8 @@ impl App { } async fn load_milestones(&self, repository: RepositoryId) -> Result { - let milestones = self.client.milestones(&repository).await.map_err(err)?; + let mut milestones = self.client.milestones(&repository).await.map_err(err)?; + sort_milestones(&mut milestones); let mut screen = Screen::new( ScreenKind::Milestones(repository.clone()), format!( @@ -1827,7 +1847,7 @@ fn issue_item(repository: &RepositoryId, issue: models::Issue) -> Option { key: number.to_string(), title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::State(work_item_state(&state)), meta: format!( "{} · {} · {} comments", state, @@ -1851,7 +1871,7 @@ fn pull_item(issue: models::Issue) -> Option { issue.title.as_deref().unwrap_or("Untitled") ), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: format!( "{} · {} comments", issue.state.as_deref().unwrap_or("unknown"), @@ -1871,19 +1891,26 @@ fn pull_item(issue: models::Issue) -> Option { fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Option { let id = milestone.id?; let state = milestone.state.unwrap_or_else(|| "unknown".into()); + let open = milestone.open_issues.unwrap_or_default(); + let closed = milestone.closed_issues.unwrap_or_default(); + let total = open.saturating_add(closed); + let due = milestone + .due_on + .as_deref() + .and_then(|date| date.get(..10)) + .map_or_else(|| "no due date".into(), |date| format!("due {date}")); Some(Item { key: id.to_string(), title: milestone .title .unwrap_or_else(|| "Untitled milestone".into()), graph_lane: None, - activity_kind: None, - meta: format!( - "{} · {} open · {} closed", - state, - milestone.open_issues.unwrap_or_default(), - milestone.closed_issues.unwrap_or_default() - ), + decoration: ItemDecoration::Milestone { + state: work_item_state(&state), + closed, + total, + }, + meta: format!("{state} · {closed} of {total} closed · {due}"), detail: milestone .description .unwrap_or_else(|| "No description".into()), @@ -1891,6 +1918,19 @@ fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Op }) } +fn sort_milestones(milestones: &mut [models::Milestone]) { + milestones.sort_by_key(|milestone| { + ( + milestone.state.as_deref() == Some("closed"), + milestone + .title + .as_deref() + .unwrap_or_default() + .to_lowercase(), + ) + }); +} + fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option { let HistoryCommit { commit, @@ -1907,7 +1947,7 @@ fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option Vec { key: path.into(), title: path.into(), graph_lane: None, - activity_kind: None, + decoration: ItemDecoration::None, meta: "changed file".into(), detail: diff_for_file(diff, path), target: Target::Text(path.into(), diff_for_file(diff, path)), @@ -1969,6 +2009,14 @@ fn state_from_detail(detail: &str) -> &str { .unwrap_or("open") } +fn work_item_state(state: &str) -> WorkItemState { + match state { + "open" => WorkItemState::Open, + "closed" => WorkItemState::Closed, + _ => WorkItemState::Unknown, + } +} + fn opposite_state(state: &str) -> &'static str { if state == "closed" { "open" } else { "closed" } } @@ -2262,4 +2310,47 @@ mod tests { "Proper release workflow · hugo/Gotcha" ); } + + #[test] + fn milestones_follow_app_order_and_expose_progress() { + let milestone = |title: &str, state: &str, open, closed| models::Milestone { + id: Some(if state == "open" { 1 } else { 2 }), + title: Some(title.into()), + state: Some(state.into()), + open_issues: Some(open), + closed_issues: Some(closed), + ..Default::default() + }; + let mut milestones = vec![ + milestone("Archived", "closed", 0, 5), + milestone("Version 2", "open", 3, 2), + milestone("Version 1", "open", 1, 0), + ]; + + sort_milestones(&mut milestones); + assert_eq!( + milestones + .iter() + .filter_map(|milestone| milestone.title.as_deref()) + .collect::>(), + ["Version 1", "Version 2", "Archived"] + ); + let item = milestone_item( + &RepositoryId { + owner: "hugo".into(), + repository: "Gotcha".into(), + }, + milestones.remove(1), + ) + .unwrap(); + assert_eq!( + item.decoration, + ItemDecoration::Milestone { + state: WorkItemState::Open, + closed: 2, + total: 5, + } + ); + assert_eq!(item.meta, "open · 2 of 5 closed · no due date"); + } } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index ddc1e33..47889de 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -16,7 +16,7 @@ use syntect::{ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::{ - app::{ActivityKind, App, ScreenKind, Tab}, + app::{ActivityKind, App, ItemDecoration, ScreenKind, Tab, WorkItemState}, editor::Editor, }; @@ -124,13 +124,14 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) { item_title( &item.title, item.graph_lane, - item.activity_kind, + item.decoration, + area.width.saturating_sub(4) as usize, + ), + item_meta( + &item.meta, + item.decoration, area.width.saturating_sub(4) as usize, ), - Line::from(Span::styled( - item.meta.clone(), - Style::default().fg(Color::DarkGray), - )), ]) }) .collect() @@ -155,7 +156,7 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) { fn item_title( title: &str, graph_lane: Option, - activity_kind: Option, + decoration: ItemDecoration, max_width: usize, ) -> Line<'static> { let mut spans = graph_lane.map_or_else(Vec::new, |node_lane| { @@ -168,8 +169,14 @@ fn item_title( )); spans }); - if let Some(kind) = activity_kind { - let (icon, color) = activity_icon(kind); + let icon = match decoration { + ItemDecoration::Activity(kind) => Some(activity_icon(kind)), + ItemDecoration::State(state) | ItemDecoration::Milestone { state, .. } => { + Some(state_icon(state)) + } + ItemDecoration::None => None, + }; + if let Some((icon, color)) = icon { spans.push(Span::styled(icon, Style::default().fg(color))); } let prefix_width = spans.iter().map(Span::width).sum::(); @@ -180,6 +187,43 @@ fn item_title( Line::from(spans) } +fn item_meta(meta: &str, decoration: ItemDecoration, max_width: usize) -> Line<'static> { + let ItemDecoration::Milestone { closed, total, .. } = decoration else { + return Line::from(Span::styled( + truncate(meta, max_width), + Style::default().fg(Color::DarkGray), + )); + }; + let bar_width = 10.min(max_width.saturating_sub(1)); + let closed = closed.max(0) as usize; + let total = total.max(0) as usize; + let filled = closed + .saturating_mul(bar_width) + .saturating_add(total / 2) + .checked_div(total) + .unwrap_or_default() + .min(bar_width); + let mut spans = vec![ + Span::styled("█".repeat(filled), Style::default().fg(Color::LightGreen)), + Span::styled( + "░".repeat(bar_width - filled), + Style::default().fg(if total == 0 { + Color::DarkGray + } else { + Color::LightYellow + }), + ), + ]; + if bar_width < max_width { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + truncate(meta, max_width - bar_width - 1), + Style::default().fg(Color::DarkGray), + )); + } + Line::from(spans) +} + fn truncate(value: &str, width: usize) -> String { if UnicodeWidthStr::width(value) <= width { return value.to_string(); @@ -213,6 +257,14 @@ fn activity_icon(kind: ActivityKind) -> (&'static str, Color) { } } +fn state_icon(state: WorkItemState) -> (&'static str, Color) { + match state { + WorkItemState::Open => ("● ", Color::LightGreen), + WorkItemState::Closed => ("✓ ", Color::LightMagenta), + WorkItemState::Unknown => ("? ", Color::DarkGray), + } +} + fn lane_color(lane: usize) -> Color { const COLORS: [Color; 6] = [ Color::LightRed, @@ -493,7 +545,7 @@ mod tests { #[test] fn commit_graph_lanes_use_stable_distinct_colors() { - let title = item_title("Merge feature", Some(2), None, 30); + let title = item_title("Merge feature", Some(2), ItemDecoration::None, 30); assert_eq!(title.to_string(), "│ │ ● Merge feature"); assert_eq!(title.spans[0].style.fg, Some(lane_color(0))); assert_eq!(title.spans[1].style.fg, Some(lane_color(1))); @@ -524,7 +576,7 @@ mod tests { let issue = item_title( "Notification settings opening issue · hugo/Gotcha", None, - Some(ActivityKind::Issue), + ItemDecoration::Activity(ActivityKind::Issue), 24, ); assert!(issue.to_string().ends_with('…')); @@ -547,6 +599,41 @@ mod tests { } } + #[test] + fn work_item_states_and_milestone_progress_have_native_terminal_treatments() { + let open = item_title( + "Open issue", + None, + ItemDecoration::State(WorkItemState::Open), + 20, + ); + let closed = item_title( + "Closed issue", + None, + ItemDecoration::State(WorkItemState::Closed), + 20, + ); + assert_eq!(open.spans[0].content, "● "); + assert_eq!(open.spans[0].style.fg, Some(Color::LightGreen)); + assert_eq!(closed.spans[0].content, "✓ "); + assert_eq!(closed.spans[0].style.fg, Some(Color::LightMagenta)); + + let progress = item_meta( + "open · 2 of 5 closed · no due date", + ItemDecoration::Milestone { + state: WorkItemState::Open, + closed: 2, + total: 5, + }, + 40, + ); + assert_eq!(progress.spans[0].content, "████"); + assert_eq!(progress.spans[1].content, "░░░░░░"); + assert_eq!(progress.spans[0].style.fg, Some(Color::LightGreen)); + assert_eq!(progress.spans[1].style.fg, Some(Color::LightYellow)); + assert!(progress.width() <= 40); + } + #[test] fn previews_render_markdown_source_and_diffs_with_styles() { let repository = gotcha_gitea::RepositoryId {