use std::collections::HashMap;
use chrono::{DateTime, FixedOffset};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
symbols::border::{self, Set as BorderSet},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
const CORNERS_ONLY: BorderSet = BorderSet {
top_left: "┌",
top_right: "┐",
bottom_left: "└",
bottom_right: "┘",
vertical_left: " ",
vertical_right: " ",
horizontal_top: " ",
horizontal_bottom: " ",
};
use crate::config::types::{CustomViewConfig, CustomViewFieldConfig};
use crate::jira::types::Issue;
use crate::tui::app::{ActionState, AppState, DetailFocus};
use crate::tui::markdown::markdown_to_lines;
use crate::tui::render::RenderOut;
pub enum DetailNavKind {
Comments,
Attachments,
}
enum Segment {
ReadOnly { lines: Vec<Line<'static>> },
NavWidget { nav: DetailNavKind, content: String },
EditableField {
label: String,
content: String,
field_idx: usize,
readonly: bool,
is_markdown: bool,
},
}
pub fn num_view_fields(cfg: Option<&CustomViewConfig>, issue: Option<&Issue>) -> usize {
cfg.map_or_else(
|| issue.map_or(0, |i| i.fields.extra.len()),
|c| c.sections.iter().map(|s| s.fields.len()).sum(),
)
}
pub fn view_field_cfg(
cfg: Option<&CustomViewConfig>,
issue: Option<&Issue>,
idx: usize,
) -> Option<CustomViewFieldConfig> {
if let Some(cfg) = cfg {
let mut count = 0;
for section in &cfg.sections {
for field in §ion.fields {
if count == idx {
return Some(field.clone());
}
count += 1;
}
}
None
} else if let Some(issue) = issue {
let mut keys: Vec<&String> = issue.fields.extra.keys().collect();
keys.sort();
let key = keys.into_iter().nth(idx)?;
Some(CustomViewFieldConfig {
field_id: key.clone(),
..Default::default()
})
} else {
None
}
}
pub fn resolve_field_label(
field: &CustomViewFieldConfig,
field_names: &HashMap<String, String>,
) -> String {
field
.name
.as_deref()
.or_else(|| field_names.get(&field.field_id).map(String::as_str))
.unwrap_or(&field.field_id)
.to_string()
}
pub fn view_editable_field_spec(
cfg: Option<&CustomViewConfig>,
issue: &Issue,
idx: usize,
) -> (String, serde_json::Value) {
let Some(field_cfg) = view_field_cfg(cfg, Some(issue), idx) else {
return (String::new(), serde_json::Value::Null);
};
let field_id = field_cfg.field_id;
let value = issue
.fields
.extra
.get(&field_id)
.cloned()
.unwrap_or(serde_json::Value::Null);
(field_id, value)
}
pub fn render_detail_view(
f: &mut Frame,
area: Rect,
issue: &Issue,
app: &AppState,
render_out: &mut RenderOut,
) -> usize {
let cfg = current_view_config(app);
let tz = resolve_tz(cfg);
let w = area.width;
let segments = build_segments(issue, cfg, tz, w, &app.field_names);
let scroll = app.detail_scroll;
let viewport_h = area.height as usize;
let mut virtual_y: usize = 0;
let num_fields = num_view_fields(cfg, Some(issue));
render_out
.detail_focus_offsets
.resize(2 + num_fields, (0, 0));
for seg in &segments {
let seg_height = measure_segment(seg, w);
let seg_top = virtual_y;
let seg_bot = virtual_y + seg_height;
virtual_y += seg_height;
match seg {
Segment::NavWidget {
nav: DetailNavKind::Comments,
..
} => {
render_out.detail_focus_offsets[0] = (seg_top, seg_bot);
}
Segment::NavWidget {
nav: DetailNavKind::Attachments,
..
} => {
render_out.detail_focus_offsets[1] = (seg_top, seg_bot);
}
Segment::EditableField { field_idx, .. }
if 2 + *field_idx < render_out.detail_focus_offsets.len() =>
{
render_out.detail_focus_offsets[2 + *field_idx] = (seg_top, seg_bot);
}
_ => {}
}
if seg_bot <= scroll || seg_top >= scroll + viewport_h {
continue;
}
let clipped_top = scroll.saturating_sub(seg_top);
#[allow(clippy::cast_possible_truncation)]
let screen_y = area.y + seg_top.saturating_sub(scroll) as u16;
let avail_rows = seg_height.saturating_sub(clipped_top);
let screen_y_rel = seg_top.saturating_sub(scroll);
let avail_rows = avail_rows.min(viewport_h.saturating_sub(screen_y_rel));
#[allow(clippy::cast_possible_truncation)]
let avail_h = avail_rows as u16;
if avail_h == 0 {
continue;
}
let rect = Rect {
x: area.x,
y: screen_y,
width: area.width,
height: avail_h,
};
render_segment(f, rect, clipped_top, seg, app);
}
virtual_y
}
pub fn current_view_config(app: &AppState) -> Option<&CustomViewConfig> {
match &app.view_mode {
crate::tui::app::ViewMode::Custom(id) => app.team_config().views.get(id.as_str()),
_ => None,
}
}
fn render_segment(f: &mut Frame, rect: Rect, clipped_top: usize, seg: &Segment, app: &AppState) {
match seg {
Segment::ReadOnly { lines } => {
#[allow(clippy::cast_possible_truncation)]
let scroll_y = clipped_top as u16;
f.render_widget(
Paragraph::new(lines.clone())
.wrap(Wrap { trim: false })
.scroll((scroll_y, 0)),
rect,
);
}
Segment::NavWidget { nav, content } => {
let selected = match nav {
DetailNavKind::Comments => {
matches!(app.detail_focus, DetailFocus::Comments)
}
DetailNavKind::Attachments => {
matches!(app.detail_focus, DetailFocus::Attachments)
}
};
let border_style = if selected {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_set(border::PLAIN)
.border_style(border_style);
let inner = block.inner(rect);
f.render_widget(block, rect);
if inner.height > 0 {
let inner_scroll = u16::try_from(clipped_top)
.unwrap_or(u16::MAX)
.saturating_sub(1);
f.render_widget(
Paragraph::new(content.as_str()).scroll((inner_scroll, 0)),
inner,
);
}
}
Segment::EditableField { .. } => {
render_editable_field(f, rect, clipped_top, seg, app);
}
}
}
fn render_editable_field(
f: &mut Frame,
rect: Rect,
clipped_top: usize,
seg: &Segment,
app: &AppState,
) {
let Segment::EditableField {
label,
field_idx,
content,
readonly,
is_markdown,
} = seg
else {
return;
};
let selected = matches!(&app.detail_focus, DetailFocus::Field(fi) if *fi == *field_idx);
let is_inline_edit = matches!(
&app.action_state,
ActionState::InlineEditingField { field_idx: fi, .. } if *fi == *field_idx
);
let border_style = if is_inline_edit {
Style::default().fg(Color::Yellow)
} else if selected && *readonly {
Style::default()
} else if selected {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let title = format!(" {label} ");
let block = if *readonly {
Block::default()
.title(title.as_str())
.borders(Borders::ALL)
.border_set(CORNERS_ONLY)
.border_style(border_style)
} else {
Block::default()
.title(title.as_str())
.borders(Borders::ALL)
.border_set(border::PLAIN)
.border_style(border_style)
};
let inner = block.inner(rect);
f.render_widget(block, rect);
#[allow(clippy::cast_possible_truncation)]
let inner_scroll = (clipped_top as u16).saturating_sub(1);
if inner.height > 0 {
if is_inline_edit {
if let ActionState::InlineEditingField {
ref input, cursor, ..
} = app.action_state
{
let line = inline_cursor_line(input, cursor);
f.render_widget(Paragraph::new(line).scroll((inner_scroll, 0)), inner);
}
} else if *is_markdown {
f.render_widget(
Paragraph::new(markdown_to_lines(content))
.wrap(Wrap { trim: false })
.scroll((inner_scroll, 0)),
inner,
);
} else {
f.render_widget(
Paragraph::new(content.as_str())
.wrap(Wrap { trim: false })
.scroll((inner_scroll, 0)),
inner,
);
}
}
}
fn build_segments(
issue: &Issue,
cfg: Option<&CustomViewConfig>,
tz: FixedOffset,
width: u16,
field_names: &HashMap<String, String>,
) -> Vec<Segment> {
let mut segs: Vec<Segment> = Vec::new();
segs.push(Segment::ReadOnly {
lines: header_lines(issue, cfg.is_none()),
});
let comment_count = issue.fields.comment.as_ref().map_or(0, |c| c.total);
segs.push(Segment::NavWidget {
nav: DetailNavKind::Comments,
content: format!("Comments ({comment_count})"),
});
let attachment_count = issue.fields.attachment.as_ref().map_or(0, Vec::len);
segs.push(Segment::NavWidget {
nav: DetailNavKind::Attachments,
content: format!("Attachments ({attachment_count})"),
});
match cfg {
Some(cfg) => {
build_custom_segments(&mut segs, issue, cfg, tz, width, field_names);
}
None => {
build_default_segments(&mut segs, issue, width, field_names);
}
}
segs
}
fn build_custom_segments(
segs: &mut Vec<Segment>,
issue: &Issue,
cfg: &CustomViewConfig,
tz: FixedOffset,
width: u16,
field_names: &HashMap<String, String>,
) {
let mut field_flat_idx = 0usize;
for (sec_idx, section) in cfg.sections.iter().enumerate() {
let sep_lines = if sec_idx == 0 {
vec![section_sep(§ion.title, width), Line::from("")]
} else {
vec![
Line::from(""),
section_sep(§ion.title, width),
Line::from(""),
]
};
segs.push(Segment::ReadOnly { lines: sep_lines });
if let Some(desc) = §ion.description {
segs.push(Segment::ReadOnly {
lines: vec![Line::from(Span::styled(
desc.clone(),
Style::default().add_modifier(Modifier::DIM),
))],
});
}
for field in §ion.fields {
let label = resolve_field_label(field, field_names);
let content = get_field_content(issue, field, tz);
let readonly = field.readonly.unwrap_or(false);
let is_markdown = issue.fields.extra.get(&field.field_id).is_some_and(is_adf);
segs.push(Segment::EditableField {
label,
content,
field_idx: field_flat_idx,
readonly,
is_markdown,
});
field_flat_idx += 1;
}
let start_field = section
.fields
.iter()
.find(|f| f.duration_role.as_deref() == Some("start"));
let end_field = section
.fields
.iter()
.find(|f| f.duration_role.as_deref() == Some("end"));
if start_field.is_some() && end_field.is_some() {
let start_dt =
start_field.and_then(|f| parse_field_dt(issue, Some(f.field_id.as_str())));
let end_dt = end_field.and_then(|f| parse_field_dt(issue, Some(f.field_id.as_str())));
let jira_h = section
.fields
.iter()
.find(|f| f.duration_role.as_deref() == Some("jira_value"))
.and_then(|f| issue.fields.extra.get(&f.field_id))
.and_then(serde_json::Value::as_f64);
segs.push(Segment::ReadOnly {
lines: duration_lines(start_dt.as_ref(), end_dt.as_ref(), jira_h),
});
}
}
}
fn build_default_segments(
segs: &mut Vec<Segment>,
issue: &Issue,
width: u16,
field_names: &HashMap<String, String>,
) {
if let Some(ref desc) = issue.fields.description {
let text = json_to_text(desc);
if !text.is_empty() {
segs.push(Segment::ReadOnly {
lines: vec![
Line::from(""),
section_sep("Description", width),
Line::from(""),
],
});
let desc_lines = markdown_to_lines(&text.replace('\r', ""));
segs.push(Segment::ReadOnly { lines: desc_lines });
}
}
if !issue.fields.extra.is_empty() {
segs.push(Segment::ReadOnly {
lines: vec![Line::from(""), section_sep("Fields", width), Line::from("")],
});
let mut extra_fields: Vec<(&String, &serde_json::Value)> =
issue.fields.extra.iter().collect();
extra_fields.sort_by_key(|(k, _)| k.as_str());
for (field_idx, (field_id, value)) in extra_fields.into_iter().enumerate() {
let label = field_names
.get(field_id)
.cloned()
.unwrap_or_else(|| field_id.clone());
let content = val_to_str(value);
segs.push(Segment::EditableField {
label,
content,
field_idx,
readonly: false,
is_markdown: is_adf(value),
});
}
}
}
fn get_field_content(issue: &Issue, field: &CustomViewFieldConfig, tz: FixedOffset) -> String {
let Some(raw) = issue.fields.extra.get(&field.field_id) else {
return String::new();
};
if raw.is_null() {
return String::new();
}
if field.datetime == Some(true)
&& let Some(s) = raw.as_str()
&& let Some(dt) = parse_dt(s)
{
return fmt_dt(&dt, tz);
}
val_to_str(raw)
}
fn measure_segment(seg: &Segment, width: u16) -> usize {
if width == 0 {
return 1;
}
match seg {
Segment::ReadOnly { lines } => lines
.iter()
.map(|l| measure_line(l, width))
.sum::<usize>()
.max(1),
Segment::NavWidget { content, .. } => {
let _ = content; 3
}
Segment::EditableField { content, .. } => {
let inner_w = (width as usize).saturating_sub(2).max(1);
let content_h = if content.is_empty() {
1
} else {
content
.lines()
.map(|l| {
let chars = l.chars().count();
if chars == 0 {
1
} else {
chars.div_ceil(inner_w)
}
})
.sum::<usize>()
.max(1)
};
2 + content_h }
}
}
fn measure_line(line: &Line, width: u16) -> usize {
let text_w: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
if text_w == 0 {
1 } else {
text_w.div_ceil(width as usize).max(1)
}
}
fn header_lines(issue: &Issue, full: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::raw(issue.fields.summary.clone()),
Span::raw(" "),
Span::styled(
issue.fields.status.name.clone(),
Style::default().add_modifier(Modifier::DIM),
),
]));
if full {
let priority = issue
.fields
.priority
.as_ref()
.map_or_else(|| "—".to_string(), |p| format!("{} {}", p.symbol(), p.name));
lines.push(kv_line("Priority", &priority));
let assignee = issue
.fields
.assignee
.as_ref()
.map_or_else(|| "Unassigned".to_string(), |a| a.display().to_string());
lines.push(kv_line("Assignee", &assignee));
if let Some(ref reporter) = issue.fields.reporter {
lines.push(kv_line("Reporter", reporter.display()));
}
lines.push(kv_line("Type", &issue.fields.issuetype.name));
lines.push(kv_line(
"Project",
&format!(
"{} ({})",
issue.fields.project.name, issue.fields.project.key
),
));
lines.push(kv_line("Key", &issue.key));
}
lines.push(Line::from(""));
lines
}
fn duration_lines(
start_dt: Option<&DateTime<FixedOffset>>,
end_dt: Option<&DateTime<FixedOffset>>,
jira_h: Option<f64>,
) -> Vec<Line<'static>> {
const DUR_PAD: usize = 28;
let mut lines: Vec<Line> = Vec::new();
match (start_dt, end_dt) {
(Some(s), Some(m)) => {
let our_mins = (m.timestamp() - s.timestamp()) / 60;
let our_str = fmt_duration(our_mins);
match jira_h {
Some(jh) => {
#[allow(clippy::cast_possible_truncation)]
let jira_mins = (jh * 60.0).round() as i64;
let mismatch = (our_mins - jira_mins).abs() > 5;
let jira_label = format!("Jira: {jh:.1}h");
let (check_str, check_style) = if mismatch {
(
format!("{jira_label} ⚠"),
Style::default().fg(Color::Yellow),
)
} else {
(
format!("{jira_label} ✓"),
Style::default().add_modifier(Modifier::DIM),
)
};
lines.push(Line::from(vec![
Span::styled(
format!("{:<14}", "Duration"),
Style::default().add_modifier(Modifier::DIM),
),
Span::raw(format!("{our_str:<DUR_PAD$}")),
Span::styled(check_str, check_style),
]));
}
None => lines.push(kv_line("Duration", &our_str)),
}
}
_ => lines.push(Line::from(vec![
Span::styled(
format!("{:<14}", "Duration"),
Style::default().add_modifier(Modifier::DIM),
),
Span::styled("(incomplete)", Style::default().add_modifier(Modifier::DIM)),
])),
}
lines.push(Line::from(""));
lines
}
fn section_sep(label: &str, width: u16) -> Line<'static> {
let labeled = format!("── {label} ");
let fill_len = (width as usize).saturating_sub(labeled.chars().count());
let fill = "─".repeat(fill_len);
Line::from(Span::styled(
format!("{labeled}{fill}"),
Style::default().add_modifier(Modifier::DIM),
))
}
fn kv_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{label:<14}"),
Style::default().add_modifier(Modifier::DIM),
),
Span::raw(value.to_string()),
])
}
fn parse_field_dt(issue: &Issue, field_id: Option<&str>) -> Option<DateTime<FixedOffset>> {
let fid = field_id?;
let v = issue.fields.extra.get(fid)?;
if v.is_null() {
return None;
}
v.as_str().and_then(parse_dt)
}
fn is_adf(v: &serde_json::Value) -> bool {
v.get("type").and_then(|t| t.as_str()) == Some("doc")
}
pub fn val_to_str(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.replace('\r', ""),
serde_json::Value::Object(_) => {
if v.get("type").and_then(|t| t.as_str()) == Some("doc") {
return json_to_text(v).replace('\r', "");
}
["value", "name", "displayName"]
.iter()
.find_map(|k| {
v.get(k)
.and_then(|x| x.as_str())
.map(|s| s.replace('\r', ""))
})
.unwrap_or_else(|| v.to_string())
}
serde_json::Value::Array(a) => a
.iter()
.map(|item| {
item.as_str()
.or_else(|| item.get("name").and_then(|n| n.as_str()))
.or_else(|| item.get("value").and_then(|n| n.as_str()))
.unwrap_or("?")
.to_string()
})
.collect::<Vec<_>>()
.join(", "),
_ => v.to_string(),
}
}
pub fn resolve_tz(cfg: Option<&CustomViewConfig>) -> FixedOffset {
cfg.and_then(|c| c.timezone.as_deref())
.and_then(parse_tz_offset)
.unwrap_or_else(local_tz)
}
fn local_tz() -> FixedOffset {
let secs = chrono::Local::now().offset().local_minus_utc();
FixedOffset::east_opt(secs)
.unwrap_or_else(|| FixedOffset::east_opt(0).expect("UTC offset 0 is always valid"))
}
fn parse_tz_offset(s: &str) -> Option<FixedOffset> {
let s = s.trim();
let sign: i32 = if s.starts_with('-') { -1 } else { 1 };
let digits = s.trim_start_matches(['+', '-']);
let h: i32 = digits.get(..2)?.parse().ok()?;
let m: i32 = digits.get(2..).and_then(|x| x.parse().ok()).unwrap_or(0);
FixedOffset::east_opt(sign * (h * 3600 + m * 60))
}
fn fmt_dt(dt: &DateTime<FixedOffset>, tz: FixedOffset) -> String {
dt.with_timezone(&tz).format("%Y-%m-%d %H:%M").to_string()
}
fn parse_dt(s: &str) -> Option<DateTime<FixedOffset>> {
DateTime::parse_from_rfc3339(s)
.or_else(|_| DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.3f%z"))
.ok()
}
fn fmt_duration(total_mins: i64) -> String {
let mins = total_mins.abs();
let h = mins / 60;
let m = mins % 60;
if h == 0 {
format!("{m}m")
} else if m == 0 {
format!("{h}h")
} else {
format!("{h}h {m}m")
}
}
fn inline_cursor_line(input: &str, cursor_char: usize) -> Line<'static> {
let chars: Vec<char> = input.chars().collect();
let mut spans: Vec<Span<'static>> = Vec::new();
if cursor_char < chars.len() {
let before: String = chars[..cursor_char].iter().collect();
let at: String = chars[cursor_char..=cursor_char].iter().collect();
let after: String = chars[cursor_char + 1..].iter().collect();
if !before.is_empty() {
spans.push(Span::raw(before));
}
spans.push(Span::styled(
at,
Style::default().add_modifier(Modifier::REVERSED),
));
if !after.is_empty() {
spans.push(Span::raw(after));
}
} else {
if !input.is_empty() {
spans.push(Span::raw(input.to_owned()));
}
spans.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::REVERSED),
));
}
Line::from(spans)
}
pub use crate::jira::adf::json_to_text;