Add milestone progress to the TUI (#69)

This commit is contained in:
Georg Bauer
2026-08-15 15:18:51 +02:00
parent e5131f663c
commit 6d271e7402
2 changed files with 209 additions and 31 deletions

View File

@@ -103,12 +103,31 @@ pub struct Item {
pub key: String,
pub title: String,
pub graph_lane: Option<usize>,
pub activity_kind: Option<ActivityKind>,
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<Screen, String> {
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<Item> {
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<Item> {
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<Item> {
fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Option<Item> {
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<Item> {
let HistoryCommit {
commit,
@@ -1907,7 +1947,7 @@ fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option<Item
key: sha.clone(),
title: message.lines().next().unwrap_or("Commit").into(),
graph_lane: node_lane,
activity_kind: None,
decoration: ItemDecoration::None,
meta: format!(
"{} · {}{}",
short_sha(&sha),
@@ -1934,7 +1974,7 @@ fn diff_files(diff: &str) -> Vec<Item> {
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::<Vec<_>>(),
["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");
}
}

View File

@@ -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<usize>,
activity_kind: Option<ActivityKind>,
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::<usize>();
@@ -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 {