diff --git a/crates/ticgit-lib/src/store.rs b/crates/ticgit-lib/src/store.rs index 4e39f197..019a7256 100644 --- a/crates/ticgit-lib/src/store.rs +++ b/crates/ticgit-lib/src/store.rs @@ -638,9 +638,13 @@ impl TicketStore { } } out.sort_by(|a, b| { - b.created_at - .cmp(&a.created_at) - .then_with(|| a.title.cmp(&b.title)) + metadata_priority_sort_key(a.priority) + .cmp(&metadata_priority_sort_key(b.priority)) + .then_with(|| { + b.created_at + .cmp(&a.created_at) + .then_with(|| a.title.cmp(&b.title)) + }) }); Ok(out) } @@ -741,6 +745,21 @@ impl TicketStore { Ok(()) } + pub fn set_writeup_priority(&self, id: &Uuid, priority: Option) -> Result<()> { + self.load_writeup(id)?; + let p = self.project_handle(); + let key = keys::writeup_field(id, "priority"); + match priority { + Some(n) => { + p.set(&key, n.to_string().as_str())?; + } + None => { + p.remove(&key)?; + } + } + Ok(()) + } + pub fn link_writeup_ticket(&self, writeup_id: &Uuid, ticket_id: &Uuid) -> Result<()> { self.load_writeup(writeup_id)?; self.load(ticket_id)?; @@ -784,6 +803,9 @@ impl TicketStore { if !body.is_empty() { self.set_description(&ticket.id, Some(&body))?; } + if writeup.priority.is_some() { + self.set_priority(&ticket.id, writeup.priority)?; + } self.link_writeup_ticket(writeup_id, &ticket.id)?; self.load(&ticket.id) } @@ -1112,6 +1134,7 @@ fn build_writeup(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option let mut title: Option = None; let mut status = WriteupStatus::Open; + let mut priority: Option = None; let mut created_at: Option = None; let mut created_by = String::new(); let mut authors = BTreeSet::new(); @@ -1125,6 +1148,7 @@ fn build_writeup(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option ("status", MetaValue::String(s)) => { status = WriteupStatus::parse(&s).unwrap_or(WriteupStatus::Open); } + ("priority", MetaValue::String(s)) => priority = s.parse().ok(), ("created-at", MetaValue::String(s)) => { created_at = OffsetDateTime::parse(&s, &Rfc3339).ok(); } @@ -1152,6 +1176,7 @@ fn build_writeup(id: Uuid, fields: Vec<(String, MetaValue)>) -> Option id, title, status, + priority, created_at, created_by, authors, @@ -1227,6 +1252,13 @@ fn decode_writeup_version(raw: &str, fallback_at: OffsetDateTime) -> WriteupVers } } +fn metadata_priority_sort_key(priority: Option) -> (u8, i64) { + match priority { + Some(value) => (0, value), + None => (1, 0), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1431,12 +1463,14 @@ mod tests { store .set_writeup_status(&writeup.id, WriteupStatus::Closed) .unwrap(); + store.set_writeup_priority(&writeup.id, Some(2)).unwrap(); store.add_writeup_tag(&writeup.id, "review").unwrap(); store.remove_writeup_tag(&writeup.id, "design").unwrap(); let loaded = store.load_writeup(&writeup.id).unwrap(); assert_eq!(loaded.title, "Design note"); assert_eq!(loaded.status, WriteupStatus::Closed); + assert_eq!(loaded.priority, Some(2)); assert!(!loaded.tags.contains("design")); assert!(loaded.tags.contains("review")); assert!(loaded.authors.contains(store.email())); @@ -1450,6 +1484,56 @@ mod tests { ); } + #[test] + fn writeups_sort_by_priority_before_recency() { + let (store, _td) = test_store(); + let old_high = store + .create_writeup( + "old high", + NewWriteupOpts { + created_at: Some( + OffsetDateTime::from_unix_timestamp(1_000).expect("valid timestamp"), + ), + ..Default::default() + }, + ) + .unwrap(); + let recent_low = store + .create_writeup( + "recent low", + NewWriteupOpts { + created_at: Some( + OffsetDateTime::from_unix_timestamp(2_000).expect("valid timestamp"), + ), + ..Default::default() + }, + ) + .unwrap(); + let no_priority = store + .create_writeup( + "no priority", + NewWriteupOpts { + created_at: Some( + OffsetDateTime::from_unix_timestamp(3_000).expect("valid timestamp"), + ), + ..Default::default() + }, + ) + .unwrap(); + store.set_writeup_priority(&old_high.id, Some(1)).unwrap(); + store.set_writeup_priority(&recent_low.id, Some(5)).unwrap(); + + let writeups = store.list_writeups().unwrap(); + + assert_eq!( + writeups + .iter() + .map(|writeup| writeup.id) + .collect::>(), + vec![old_high.id, recent_low.id, no_priority.id] + ); + } + #[test] fn writeups_link_unlink_and_promote() { let (store, _td) = test_store(); @@ -1464,6 +1548,7 @@ mod tests { }, ) .unwrap(); + store.set_writeup_priority(&writeup.id, Some(3)).unwrap(); store.link_writeup_ticket(&writeup.id, &ticket.id).unwrap(); assert!(store @@ -1486,6 +1571,7 @@ mod tests { promoted.description.as_deref(), Some("make this actionable") ); + assert_eq!(promoted.priority, Some(3)); assert!(promoted.tags.contains("feature")); assert!(store .load_writeup(&writeup.id) diff --git a/crates/ticgit-lib/src/writeup.rs b/crates/ticgit-lib/src/writeup.rs index 25e07120..f4ec88a8 100644 --- a/crates/ticgit-lib/src/writeup.rs +++ b/crates/ticgit-lib/src/writeup.rs @@ -39,6 +39,7 @@ pub struct Writeup { pub id: Uuid, pub title: String, pub status: WriteupStatus, + pub priority: Option, pub created_at: OffsetDateTime, pub created_by: String, pub authors: BTreeSet, diff --git a/crates/ticgit/src/commands/tui.rs b/crates/ticgit/src/commands/tui.rs index ba494566..f063348e 100644 --- a/crates/ticgit/src/commands/tui.rs +++ b/crates/ticgit/src/commands/tui.rs @@ -202,6 +202,7 @@ struct App { tag_picker_state: ListState, manage_tag_state: ListState, link_issue_state: ListState, + writeup_toc_state: ListState, version_state: ListState, order_state: ListState, column_state: ListState, @@ -210,6 +211,9 @@ struct App { new_ticket: NewTicketDraft, detail: Option, writeup_detail: Option, + writeup_detail_focus: WriteupPaneFocus, + writeup_detail_scroll: u16, + writeup_toc_open: bool, detail_width_percent: u16, comments_mode: bool, comment_state: ListState, @@ -232,6 +236,14 @@ enum TuiTab { Dashboard, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum WriteupPaneFocus { + #[default] + List, + Detail, + Toc, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TagTarget { Ticket(uuid::Uuid), @@ -261,6 +273,20 @@ struct ViewEntry { kind: ViewKind, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct MarkdownHeading { + level: usize, + title: String, + line: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WriteupBodyStats { + words: usize, + read_minutes: usize, + headings: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Mode { Normal, @@ -405,6 +431,7 @@ impl App { tag_picker_state: ListState::default(), manage_tag_state: ListState::default(), link_issue_state: ListState::default(), + writeup_toc_state: ListState::default(), version_state: ListState::default(), order_state: ListState::default(), column_state: ListState::default(), @@ -413,6 +440,9 @@ impl App { new_ticket: NewTicketDraft::default(), detail: None, writeup_detail: None, + writeup_detail_focus: WriteupPaneFocus::List, + writeup_detail_scroll: 0, + writeup_toc_open: false, detail_width_percent, comments_mode: false, comment_state: ListState::default(), @@ -986,6 +1016,66 @@ impl App { desc: "quit", }, ] + } else if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + let move_hint = if self.writeup_detail_focus == WriteupPaneFocus::Detail { + MenuHint { + key: "j/k", + desc: "scroll", + } + } else if self.writeup_detail_focus == WriteupPaneFocus::Toc { + MenuHint { + key: "j/k", + desc: "contents", + } + } else { + MenuHint { + key: "j/k", + desc: "writeups", + } + }; + vec![ + MenuHint { + key: "Tab", + desc: "issues", + }, + move_hint, + MenuHint { + key: "h/l", + desc: "pane", + }, + MenuHint { + key: "Enter", + desc: "jump/open", + }, + MenuHint { + key: "i/u", + desc: "link", + }, + MenuHint { + key: "t", + desc: "contents", + }, + MenuHint { + key: "v", + desc: "versions", + }, + MenuHint { + key: "e", + desc: "edit", + }, + MenuHint { + key: "+/-", + desc: "resize", + }, + MenuHint { + key: "Esc", + desc: "close", + }, + MenuHint { + key: "q", + desc: "quit", + }, + ] } else if self.active_tab == TuiTab::Writeups { vec![ MenuHint { @@ -1018,6 +1108,10 @@ impl App { }, MenuHint { key: "p", + desc: "priority", + }, + MenuHint { + key: "P", desc: "promote", }, MenuHint { @@ -1033,7 +1127,7 @@ impl App { desc: "close/open", }, MenuHint { - key: "l/u", + key: "i/u", desc: "link", }, MenuHint { @@ -1362,7 +1456,16 @@ impl App { }; let block = Block::default() .borders(Borders::ALL) - .title(tabs_title(self.active_tab, &title)); + .title(tabs_title(self.active_tab, &title)) + .border_style( + if self.writeup_detail.is_some() + && self.writeup_detail_focus == WriteupPaneFocus::List + { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }, + ); let row_width = usize::from(block.inner(area).width) .saturating_sub(UnicodeWidthStr::width(HIGHLIGHT_SYMBOL)); let compact = self.writeup_detail.is_some(); @@ -1578,80 +1681,92 @@ impl App { frame.render_widget(detail, area); } - fn draw_writeup_detail(&self, frame: &mut Frame<'_>, area: Rect) { + fn draw_writeup_detail(&mut self, frame: &mut Frame<'_>, area: Rect) { let Some(idx) = self.writeup_detail else { return; }; let writeup = &self.writeups[idx]; - let mut lines = vec![Line::from(Span::styled( - writeup.id.to_string(), - Style::default().fg(Color::DarkGray), - ))]; - lines.extend(writeup_metadata_lines( - writeup, - usize::from(area.width).saturating_sub(2), - )); - if !writeup.tags.is_empty() { - lines.push(tags_field_line(&writeup.tags)); - } - if !writeup.tickets.is_empty() { - lines.push(Line::raw("")); - lines.push(Line::from(Span::styled( - "Issues", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ))); - for (idx, ticket_id) in writeup.tickets.iter().take(9).enumerate() { - let ticket = self.tickets.iter().find(|ticket| ticket.id == *ticket_id); - let title = ticket - .map(|ticket| ticket.title.clone()) - .unwrap_or_else(|| "missing ticket".to_string()); - lines.push(Line::from(vec![ - Span::styled( - format!("{}", idx + 1), + let toc_visible = self.writeup_toc_open; + let (detail_area, toc_area) = if toc_visible { + let toc_width = (area.width / 3).clamp(16, 36); + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(12), Constraint::Length(toc_width)]) + .split(area); + (panes[0], Some(panes[1])) + } else { + (area, None) + }; + let (lines, headings) = + writeup_detail_lines(writeup, &self.tickets, usize::from(detail_area.width)); + self.sync_writeup_toc_selection(&headings); + + let detail = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Writeup") + .border_style(if self.writeup_detail_focus == WriteupPaneFocus::Detail { + Style::default().fg(Color::Cyan) + } else { Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - ticket_id.to_string()[..6].to_string(), - Style::default().fg(Color::DarkGray), - ), - Span::raw(" "), - Span::raw(title), - ])); - } + }), + ) + .wrap(Wrap { trim: false }) + .scroll((self.writeup_detail_scroll, 0)); + frame.render_widget(detail, detail_area); + + if let Some(toc_area) = toc_area { + self.draw_writeup_toc(frame, toc_area, &headings); } - lines.push(Line::raw("")); - if let Some(version) = writeup.versions.last() { - lines.push(Line::from(Span::styled( - writeup.title.clone(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ))); - lines.push(Line::raw("")); - lines.extend(version.body.lines().map(|line| Line::raw(line.to_string()))); + } + + fn draw_writeup_toc( + &mut self, + frame: &mut Frame<'_>, + area: Rect, + headings: &[MarkdownHeading], + ) { + let items: Vec> = if headings.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No headings", + Style::default().fg(Color::DarkGray), + )))] } else { - lines.push(Line::from(Span::styled( - writeup.title.clone(), + headings + .iter() + .map(|heading| { + let indent = " ".repeat(heading.level.saturating_sub(1).min(5)); + ListItem::new(Line::from(vec![ + Span::raw(indent), + Span::raw(truncate_display( + &heading.title, + usize::from(area.width).saturating_sub(4), + )), + ])) + }) + .collect() + }; + let toc = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Contents") + .border_style(if self.writeup_detail_focus == WriteupPaneFocus::Toc { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }), + ) + .highlight_style( Style::default() - .fg(Color::Cyan) + .bg(Color::Rgb(0, 0, 95)) + .fg(Color::White) .add_modifier(Modifier::BOLD), - ))); - lines.push(Line::raw("")); - lines.push(Line::from(Span::styled( - "No versions yet. Press e to add one.", - Style::default().fg(Color::DarkGray), - ))); - } - - let detail = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL).title("Writeup")) - .wrap(Wrap { trim: false }); - frame.render_widget(detail, area); + ) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always); + frame.render_stateful_widget(toc, area, &mut self.writeup_toc_state); } fn draw_comments_list(&mut self, frame: &mut Frame<'_>, area: Rect) { @@ -2622,6 +2737,33 @@ impl App { )); lines.push(help_columns(("r", "refresh"), None)); } + Mode::Normal + if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() => + { + help_section(&mut lines, "Writeup Detail"); + lines.push(help_columns( + ("h/l", "switch pane"), + Some(("Esc", "close detail")), + )); + lines.push(help_columns( + ("j/k", "move or scroll"), + Some(("Up/Down", "move or scroll")), + )); + lines.push(help_columns( + ("t", "contents"), + Some(("Enter", "jump heading")), + )); + lines.push(help_columns( + ("i", "link issue"), + Some(("u", "unlink issue")), + )); + lines.push(help_columns(("p", "priority"), Some(("P", "promote")))); + lines.push(help_columns(("e", "edit latest"), Some(("v", "versions")))); + lines.push(help_columns( + ("+/-", "resize detail"), + Some(("1-9", "jump issue")), + )); + } Mode::Normal if self.active_tab == TuiTab::Writeups => { help_section(&mut lines, "Writeups"); lines.push(help_columns( @@ -2637,9 +2779,12 @@ impl App { Some(("a", "show all/open")), )); lines.push(help_columns(("c", "close"), Some(("o", "reopen")))); - lines.push(help_columns(("p", "promote"), Some(("l", "link issue")))); + lines.push(help_columns(("p", "priority"), Some(("P", "promote")))); + lines.push(help_columns( + ("i", "link issue"), + Some(("u", "unlink issue")), + )); lines.push(help_columns(("v", "versions"), Some(("t", "manage tags")))); - lines.push(help_columns(("u", "unlink issue"), None)); lines.push(help_columns(("1-9", "jump issue"), None)); lines.push(help_columns( ("+/-", "resize detail"), @@ -2840,8 +2985,17 @@ impl App { if self.comments_mode { self.comments_mode = false; false + } else if self.active_tab == TuiTab::Writeups + && self.writeup_detail_focus == WriteupPaneFocus::Toc + { + self.writeup_toc_open = false; + self.writeup_detail_focus = WriteupPaneFocus::Detail; + false } else if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { self.writeup_detail = None; + self.writeup_detail_focus = WriteupPaneFocus::List; + self.writeup_detail_scroll = 0; + self.writeup_toc_open = false; false } else if self.detail.is_some() { self.detail = None; @@ -2869,7 +3023,14 @@ impl App { false } KeyCode::Char('t') => { - self.begin_manage_tags(); + if self.active_tab == TuiTab::Writeups + && self.writeup_detail.is_some() + && self.writeup_detail_focus != WriteupPaneFocus::List + { + self.toggle_writeup_toc(); + } else { + self.begin_manage_tags(); + } false } KeyCode::Char('v') => { @@ -2933,6 +3094,14 @@ impl App { KeyCode::Down | KeyCode::Char('j') => { if self.comments_mode { self.next_comment(); + } else if self.active_tab == TuiTab::Writeups + && self.writeup_detail_focus == WriteupPaneFocus::Detail + { + self.scroll_writeup_detail(1); + } else if self.active_tab == TuiTab::Writeups + && self.writeup_detail_focus == WriteupPaneFocus::Toc + { + self.next_writeup_heading(); } else if self.active_tab == TuiTab::Issues && self.view == ViewMode::Board && self.detail.is_none() @@ -2946,6 +3115,14 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if self.comments_mode { self.previous_comment(); + } else if self.active_tab == TuiTab::Writeups + && self.writeup_detail_focus == WriteupPaneFocus::Detail + { + self.scroll_writeup_detail(-1); + } else if self.active_tab == TuiTab::Writeups + && self.writeup_detail_focus == WriteupPaneFocus::Toc + { + self.previous_writeup_heading(); } else if self.active_tab == TuiTab::Issues && self.view == ViewMode::Board && self.detail.is_none() @@ -2958,7 +3135,7 @@ impl App { } KeyCode::Right | KeyCode::Char('l') => { if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { - self.begin_link_issue_search(); + self.focus_next_writeup_pane(); } else if self.active_tab == TuiTab::Issues && self.view == ViewMode::Board && self.detail.is_none() @@ -2968,7 +3145,9 @@ impl App { false } KeyCode::Left | KeyCode::Char('h') => { - if self.active_tab == TuiTab::Issues + if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + self.focus_previous_writeup_pane(); + } else if self.active_tab == TuiTab::Issues && self.view == ViewMode::Board && self.detail.is_none() { @@ -2977,7 +3156,13 @@ impl App { false } KeyCode::Enter => { - self.open_selected(); + if self.active_tab == TuiTab::Writeups + && self.writeup_detail_focus == WriteupPaneFocus::Toc + { + self.jump_to_selected_writeup_heading(); + } else { + self.open_selected(); + } false } KeyCode::Char('e') => { @@ -2989,7 +3174,9 @@ impl App { false } KeyCode::Char('i') => { - if self.active_tab == TuiTab::Issues { + if self.active_tab == TuiTab::Writeups && self.writeup_detail.is_some() { + self.begin_link_issue_search(); + } else if self.active_tab == TuiTab::Issues { self.edit_spec_in_editor(terminal)?; } false @@ -3016,14 +3203,16 @@ impl App { } KeyCode::Char('p') => { if self.active_tab == TuiTab::Writeups { - self.promote_selected_writeup()?; + self.begin_input(InputKind::Priority); } else { self.begin_input(InputKind::Priority); } false } KeyCode::Char('P') => { - if self.active_tab == TuiTab::Issues { + if self.active_tab == TuiTab::Writeups { + self.promote_selected_writeup()?; + } else if self.active_tab == TuiTab::Issues { self.jump_to_parent_issue(); } false @@ -3933,6 +4122,10 @@ impl App { self.view = ViewMode::List; self.comments_mode = false; self.writeup_detail = Some(idx); + self.writeup_detail_focus = WriteupPaneFocus::Detail; + self.writeup_detail_scroll = 0; + self.writeup_toc_open = false; + self.writeup_toc_state.select(None); if let Some(visible_pos) = self .visible_writeups .iter() @@ -4033,6 +4226,18 @@ impl App { } None } + InputKind::Priority if self.active_tab == TuiTab::Writeups => { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return; + }; + self.input = writeup + .priority + .map(|value| value.to_string()) + .unwrap_or_default(); + self.mode = Mode::Input(kind); + return; + } _ => { let Some(ticket) = self.selected_ticket() else { self.status = Some("Select a ticket first.".to_string()); @@ -4043,7 +4248,10 @@ impl App { }; self.input = match kind { - InputKind::Priority => String::new(), + InputKind::Priority => ticket + .and_then(|ticket| ticket.priority) + .map(|value| value.to_string()) + .unwrap_or_default(), InputKind::Points => ticket .and_then(|ticket| ticket.points) .map(|value| value.to_string()) @@ -4100,10 +4308,20 @@ impl App { } fn priority_range_display(&self) -> String { - let mut priorities = self - .visible - .iter() - .filter_map(|idx| self.tickets[*idx].priority); + let mut priorities: Box + '_> = + if self.active_tab == TuiTab::Writeups { + Box::new( + self.visible_writeups + .iter() + .filter_map(|idx| self.writeups[*idx].priority), + ) + } else { + Box::new( + self.visible + .iter() + .filter_map(|idx| self.tickets[*idx].priority), + ) + }; let Some(first) = priorities.next() else { return "No priorities set.".to_string(); }; @@ -4124,6 +4342,9 @@ impl App { if matches!(kind, InputKind::AddTags | InputKind::RemoveTags) { return self.submit_tag_input(kind); } + if kind == InputKind::Priority && self.active_tab == TuiTab::Writeups { + return self.submit_writeup_priority_input(); + } let Some(ticket) = self.selected_ticket() else { self.status = Some("Select a ticket first.".to_string()); @@ -4172,6 +4393,28 @@ impl App { Ok(true) } + fn submit_writeup_priority_input(&mut self) -> Result { + let Some(writeup) = self.selected_writeup() else { + self.status = Some("Select a writeup first.".to_string()); + return Ok(false); + }; + let id = writeup.id; + let priority = match parse_optional_i64(&self.input, "priority") { + Ok(priority) => priority, + Err(err) => { + self.status = Some(err.to_string()); + return Ok(false); + } + }; + self.store.set_writeup_priority(&id, priority)?; + self.status = Some(match priority { + Some(value) => format!("Set writeup priority to {value}."), + None => "Cleared writeup priority.".to_string(), + }); + self.reload_writeups(Some(id))?; + Ok(true) + } + fn submit_tag_input(&mut self, kind: InputKind) -> Result { let Some((target, _, _)) = self.selected_tag_target() else { self.status = Some("Select an issue or writeup first.".to_string()); @@ -4789,10 +5032,18 @@ impl App { } }) .collect(); + self.visible_writeups + .sort_by(|a, b| compare_tui_writeups(&self.writeups[*a], &self.writeups[*b])); self.writeup_detail = self .writeup_detail .filter(|idx| self.visible_writeups.contains(idx)); + if self.writeup_detail.is_none() { + self.writeup_detail_focus = WriteupPaneFocus::List; + self.writeup_detail_scroll = 0; + self.writeup_toc_open = false; + self.writeup_toc_state.select(None); + } if self.visible_writeups.is_empty() { self.writeup_state.select(None); } else { @@ -4869,6 +5120,127 @@ impl App { self.sync_open_writeup_detail(); } + fn focus_next_writeup_pane(&mut self) { + self.writeup_detail_focus = match self.writeup_detail_focus { + WriteupPaneFocus::List => WriteupPaneFocus::Detail, + WriteupPaneFocus::Detail if self.writeup_toc_open => WriteupPaneFocus::Toc, + WriteupPaneFocus::Detail | WriteupPaneFocus::Toc => WriteupPaneFocus::Detail, + }; + } + + fn focus_previous_writeup_pane(&mut self) { + self.writeup_detail_focus = match self.writeup_detail_focus { + WriteupPaneFocus::Toc => WriteupPaneFocus::Detail, + WriteupPaneFocus::Detail => WriteupPaneFocus::List, + WriteupPaneFocus::List => WriteupPaneFocus::List, + }; + } + + fn scroll_writeup_detail(&mut self, delta: i16) { + self.writeup_detail_scroll = if delta.is_negative() { + self.writeup_detail_scroll + .saturating_sub(delta.unsigned_abs()) + } else { + self.writeup_detail_scroll.saturating_add(delta as u16) + }; + self.sync_writeup_toc_to_scroll(); + } + + fn toggle_writeup_toc(&mut self) { + if self.writeup_toc_open { + self.writeup_toc_open = false; + self.writeup_detail_focus = WriteupPaneFocus::Detail; + return; + } + let headings = self.current_writeup_headings(); + if headings.is_empty() { + self.status = Some("No markdown headings in this writeup.".to_string()); + return; + } + self.writeup_toc_open = true; + self.writeup_detail_focus = WriteupPaneFocus::Toc; + self.sync_writeup_toc_selection(&headings); + } + + fn current_writeup_headings(&self) -> Vec { + let Some(writeup) = self.writeup_detail.map(|idx| &self.writeups[idx]) else { + return Vec::new(); + }; + let (_, headings) = writeup_detail_lines(writeup, &[], usize::MAX); + headings + } + + fn next_writeup_heading(&mut self) { + let headings = self.current_writeup_headings(); + if headings.is_empty() { + self.writeup_toc_state.select(None); + return; + } + let selected = self.writeup_toc_state.selected().unwrap_or(0); + self.writeup_toc_state + .select(Some((selected + 1) % headings.len())); + } + + fn previous_writeup_heading(&mut self) { + let headings = self.current_writeup_headings(); + if headings.is_empty() { + self.writeup_toc_state.select(None); + return; + } + let selected = self.writeup_toc_state.selected().unwrap_or(0); + let previous = selected + .checked_sub(1) + .unwrap_or_else(|| headings.len().saturating_sub(1)); + self.writeup_toc_state.select(Some(previous)); + } + + fn jump_to_selected_writeup_heading(&mut self) { + let headings = self.current_writeup_headings(); + let Some(heading) = self + .writeup_toc_state + .selected() + .and_then(|selected| headings.get(selected)) + else { + self.status = Some("No heading selected.".to_string()); + return; + }; + self.writeup_detail_scroll = heading.line.min(usize::from(u16::MAX)) as u16; + self.writeup_detail_focus = WriteupPaneFocus::Detail; + } + + fn sync_writeup_toc_selection(&mut self, headings: &[MarkdownHeading]) { + if headings.is_empty() { + self.writeup_toc_state.select(None); + return; + } + let selected = self + .writeup_toc_state + .selected() + .unwrap_or(0) + .min(headings.len() - 1); + self.writeup_toc_state.select(Some(selected)); + } + + fn sync_writeup_toc_to_scroll(&mut self) { + if !self.writeup_toc_open { + return; + } + let headings = self.current_writeup_headings(); + if headings.is_empty() { + self.writeup_toc_state.select(None); + return; + } + let scroll = usize::from(self.writeup_detail_scroll); + let selected = headings + .iter() + .enumerate() + .take_while(|(_, heading)| heading.line <= scroll) + .map(|(idx, _)| idx) + .last() + .unwrap_or(0); + self.writeup_toc_state.select(Some(selected)); + } + fn resize_detail(&mut self, delta: i16) { if self.detail.is_none() && self.writeup_detail.is_none() { self.status = Some("Open details first.".to_string()); @@ -5029,7 +5401,14 @@ impl App { fn open_selected_writeup(&mut self) { if let Some(idx) = self.selected_writeup_index() { + if self.writeup_detail != Some(idx) { + self.writeup_detail_scroll = 0; + self.writeup_toc_state.select(None); + } self.writeup_detail = Some(idx); + if self.writeup_detail_focus == WriteupPaneFocus::Toc && !self.writeup_toc_open { + self.writeup_detail_focus = WriteupPaneFocus::Detail; + } if let Some(visible_pos) = self .visible_writeups .iter() @@ -5451,6 +5830,13 @@ fn compare_tui_tickets(a: &Ticket, b: &Ticket) -> std::cmp::Ordering { .then_with(|| a.id.cmp(&b.id)) } +fn compare_tui_writeups(a: &Writeup, b: &Writeup) -> std::cmp::Ordering { + priority_sort_key(a.priority) + .cmp(&priority_sort_key(b.priority)) + .then_with(|| writeup_recent_at(b).cmp(&writeup_recent_at(a))) + .then_with(|| a.id.cmp(&b.id)) +} + fn closed_at_for( closed_at: &HashMap, ticket: &Ticket, @@ -6019,6 +6405,16 @@ fn writeup_list_line(writeup: &Writeup, width: usize, compact: bool) -> Line<'st .fg(Color::DarkGray) .add_modifier(Modifier::DIM), ), + ( + fit_display( + &writeup + .priority + .map(|priority| format!("p{priority}")) + .unwrap_or_else(|| "-".to_string()), + LIST_PRIORITY_WIDTH, + ), + Style::default().fg(Color::LightMagenta), + ), ( fit_display(&format!("v{}", writeup.versions.len()), LIST_STATE_WIDTH), Style::default().fg(Color::LightBlue), @@ -7084,6 +7480,298 @@ fn detail_child_issue_line(value: &str) -> Line<'static> { ]) } +fn writeup_detail_lines( + writeup: &Writeup, + tickets: &[Ticket], + width: usize, +) -> (Vec>, Vec) { + let mut lines = vec![Line::from(Span::styled( + writeup.id.to_string(), + Style::default().fg(Color::DarkGray), + ))]; + lines.extend(writeup_metadata_lines(writeup, width.saturating_sub(2))); + if !writeup.tags.is_empty() { + lines.push(tags_field_line(&writeup.tags)); + } + if let Some(priority) = writeup.priority { + lines.push(field_line("Priority", &priority.to_string())); + } + if let Some(body) = writeup.latest_body() { + let stats = writeup_body_stats(body); + lines.push(field_line("Stats", &writeup_stats_display(stats))); + } + if !writeup.tickets.is_empty() { + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Issues", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + for (idx, ticket_id) in writeup.tickets.iter().take(9).enumerate() { + let ticket = tickets.iter().find(|ticket| ticket.id == *ticket_id); + let title = ticket + .map(|ticket| ticket.title.clone()) + .unwrap_or_else(|| "missing ticket".to_string()); + lines.push(Line::from(vec![ + Span::styled( + format!("{}", idx + 1), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + ticket_id.to_string()[..6].to_string(), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::raw(title), + ])); + } + } + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + writeup.title.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::raw("")); + let body_start = lines.len(); + if let Some(version) = writeup.versions.last() { + lines.extend(markdown_body_lines(&version.body)); + let headings = parse_markdown_headings(&version.body) + .into_iter() + .map(|heading| MarkdownHeading { + line: heading.line + body_start, + ..heading + }) + .collect(); + (lines, headings) + } else { + lines.push(Line::from(Span::styled( + "No versions yet. Press e to add one.", + Style::default().fg(Color::DarkGray), + ))); + (lines, Vec::new()) + } +} + +fn writeup_body_stats(body: &str) -> WriteupBodyStats { + let words = body + .split(|ch: char| !ch.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .count(); + WriteupBodyStats { + words, + read_minutes: words.div_ceil(200).max(usize::from(words > 0)), + headings: parse_markdown_headings(body).len(), + } +} + +fn writeup_stats_display(stats: WriteupBodyStats) -> String { + let word_label = if stats.words == 1 { "word" } else { "words" }; + let heading_label = if stats.headings == 1 { + "heading" + } else { + "headings" + }; + format!( + "{} {word_label}, {} min read, {} {heading_label}", + stats.words, stats.read_minutes, stats.headings + ) +} + +fn markdown_body_lines(body: &str) -> Vec> { + let mut lines = Vec::new(); + let mut in_fence = false; + for line in body.lines() { + let trimmed_start = line.trim_start(); + if trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~") { + in_fence = !in_fence; + lines.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(Color::DarkGray), + ))); + continue; + } + if in_fence { + lines.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(Color::Green), + ))); + } else { + lines.push(markdown_line(line)); + } + } + lines +} + +fn markdown_line(line: &str) -> Line<'static> { + let leading = line.len().saturating_sub(line.trim_start().len()); + let trimmed_start = line.trim_start(); + if let Some((level, title)) = markdown_heading(trimmed_start) { + let color = markdown_heading_color(level); + let mut spans = Vec::new(); + if leading > 0 { + spans.push(Span::raw(" ".repeat(leading))); + } + spans.push(Span::styled( + title, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + return Line::from(spans); + } + + if is_markdown_rule(trimmed_start) { + return Line::from(Span::styled( + "─".repeat(trimmed_start.chars().count().max(3)), + Style::default().fg(Color::DarkGray), + )); + } + + if let Some(rest) = trimmed_start.strip_prefix(">") { + let mut spans = Vec::new(); + if leading > 0 { + spans.push(Span::raw(" ".repeat(leading))); + } + spans.push(Span::styled(">", Style::default().fg(Color::DarkGray))); + spans.extend(markdown_inline_spans( + rest.trim_start(), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), + )); + return Line::from(spans); + } + + if let Some((marker, rest)) = markdown_list_marker(trimmed_start) { + let mut spans = Vec::new(); + if leading > 0 { + spans.push(Span::raw(" ".repeat(leading))); + } + spans.push(Span::styled(marker, Style::default().fg(Color::Yellow))); + spans.push(Span::raw(" ")); + spans.extend(markdown_inline_spans(rest, Style::default())); + return Line::from(spans); + } + + Line::from(markdown_inline_spans(line, Style::default())) +} + +fn markdown_heading(line: &str) -> Option<(usize, String)> { + let hashes = line.chars().take_while(|ch| *ch == '#').count(); + if !(1..=6).contains(&hashes) { + return None; + } + let after_hashes = &line[hashes..]; + if !after_hashes.chars().next().is_some_and(char::is_whitespace) { + return None; + } + let title = after_hashes.trim().trim_end_matches('#').trim().to_string(); + (!title.is_empty()).then_some((hashes, title)) +} + +fn markdown_heading_color(level: usize) -> Color { + match level { + 1 => Color::Cyan, + 2 => Color::LightCyan, + 3 => Color::Yellow, + 4 => Color::LightYellow, + 5 => Color::Magenta, + _ => Color::Gray, + } +} + +fn is_markdown_rule(line: &str) -> bool { + let mut chars = line.chars(); + let Some(marker @ ('-' | '_' | '*')) = chars.next() else { + return false; + }; + let mut count = 1; + for ch in chars { + if ch.is_whitespace() { + continue; + } + if ch != marker { + return false; + } + count += 1; + } + count >= 3 +} + +fn markdown_list_marker(line: &str) -> Option<(String, &str)> { + for marker in ["- ", "* ", "+ "] { + if let Some(rest) = line.strip_prefix(marker) { + return Some((marker.trim().to_string(), rest)); + } + } + let marker_end = line.find(". ")?; + if marker_end == 0 || !line[..marker_end].chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + Some((line[..=marker_end].to_string(), &line[marker_end + 2..])) +} + +fn markdown_inline_spans(text: &str, base_style: Style) -> Vec> { + let mut spans = Vec::new(); + let mut rest = text; + while !rest.is_empty() { + let next = ["**", "__", "`"] + .iter() + .filter_map(|marker| rest.find(marker).map(|idx| (idx, *marker))) + .min_by_key(|(idx, _)| *idx); + let Some((idx, marker)) = next else { + spans.push(Span::styled(rest.to_string(), base_style)); + break; + }; + if idx > 0 { + spans.push(Span::styled(rest[..idx].to_string(), base_style)); + } + let marker_len = marker.len(); + let after_marker = &rest[idx + marker_len..]; + if let Some(end) = after_marker.find(marker) { + let content = &after_marker[..end]; + let style = if marker == "`" { + Style::default().fg(Color::Yellow).bg(Color::DarkGray) + } else { + base_style.add_modifier(Modifier::BOLD) + }; + spans.push(Span::styled(content.to_string(), style)); + rest = &after_marker[end + marker_len..]; + } else { + spans.push(Span::styled(marker.to_string(), base_style)); + rest = after_marker; + } + } + spans +} + +fn parse_markdown_headings(body: &str) -> Vec { + let mut headings = Vec::new(); + let mut in_fence = false; + for (line_idx, line) in body.lines().enumerate() { + let trimmed_start = line.trim_start(); + if trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + if let Some((hashes, title)) = markdown_heading(trimmed_start) { + headings.push(MarkdownHeading { + level: hashes, + title, + line: line_idx, + }); + } + } + headings +} + #[derive(Clone)] struct MetadataField { key: &'static str, @@ -7628,6 +8316,78 @@ mod tests { ); } + #[test] + fn markdown_headings_ignore_code_fences_and_track_levels() { + let headings = parse_markdown_headings( + "# Intro\ntext\n```md\n# ignored\n```\n## Details ##\n#### Deep\nnot # heading", + ); + + assert_eq!( + headings, + vec![ + MarkdownHeading { + level: 1, + title: "Intro".to_string(), + line: 0, + }, + MarkdownHeading { + level: 2, + title: "Details".to_string(), + line: 5, + }, + MarkdownHeading { + level: 4, + title: "Deep".to_string(), + line: 6, + }, + ] + ); + } + + #[test] + fn markdown_line_styles_headers() { + let line = markdown_line("## Details ##"); + + assert_eq!(line.spans[0].content.as_ref(), "Details"); + assert_eq!(line.spans[0].style.fg, Some(Color::LightCyan)); + assert!(line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn markdown_line_styles_bold_and_code_spans() { + let line = markdown_line("Use **bold** and `code` here"); + + assert_eq!(line.spans[1].content.as_ref(), "bold"); + assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(line.spans[3].content.as_ref(), "code"); + assert_eq!(line.spans[3].style.fg, Some(Color::Yellow)); + assert_eq!(line.spans[3].style.bg, Some(Color::DarkGray)); + } + + #[test] + fn markdown_body_lines_style_fenced_code_without_headings() { + let lines = markdown_body_lines("```md\n# not heading\n```\n# Heading"); + + assert_eq!(lines[1].spans[0].content.as_ref(), "# not heading"); + assert_eq!(lines[1].spans[0].style.fg, Some(Color::Green)); + assert_eq!(lines[3].spans[0].content.as_ref(), "Heading"); + assert_eq!(lines[3].spans[0].style.fg, Some(Color::Cyan)); + } + + #[test] + fn writeup_body_stats_count_words_read_time_and_headings() { + let body = "# Intro\none two three\n## Next\nfour"; + + assert_eq!( + writeup_body_stats(body), + WriteupBodyStats { + words: 6, + read_minutes: 1, + headings: 2, + } + ); + } + fn test_ticket(id: uuid::Uuid, parent: Option, children: &[uuid::Uuid]) -> Ticket { Ticket { id, diff --git a/crates/ticgit/src/commands/writeup.rs b/crates/ticgit/src/commands/writeup.rs index 6c495ebc..a67aecde 100644 --- a/crates/ticgit/src/commands/writeup.rs +++ b/crates/ticgit/src/commands/writeup.rs @@ -166,9 +166,13 @@ fn run_list(args: ListArgs) -> Result<()> { ) }; println!( - "{} {:<6} {:<6} v{} {}{}", + "{} {:<6} {:<3} {:<6} v{} {}{}", writeup.short_id(), writeup.status.as_str(), + writeup + .priority + .map(|priority| format!("p{priority}")) + .unwrap_or_else(|| "-".to_string()), writeup.authors.len(), writeup.versions.len(), writeup.title, @@ -192,22 +196,40 @@ fn run_edit(args: EditArgs) -> Result<()> { let store = open_store()?; let id = store.resolve_writeup_id(&args.id)?; let current = store.load_writeup(&id)?; - let initial = current.latest_body().unwrap_or(""); + let initial = writeup_edit_body(¤t); let body = match (args.body, args.file) { - (Some(body), None) => body, + (Some(body), None) => { + store.append_writeup_version(&id, &body)?; + let writeup = store.load_writeup(&id)?; + println!( + "Appended version {} to writeup {}.", + writeup.versions.len(), + writeup.short_id() + ); + return Ok(()); + } (None, Some(path)) => std::fs::read_to_string(&path) .with_context(|| format!("reading writeup body from `{}`", path.display()))?, - (None, None) => editor::capture_with_initial("Writeup body", initial)? + (None, None) => editor::capture_with_initial("Writeup body", &initial)? .context("writeup edit cancelled")?, (Some(_), Some(_)) => unreachable!("clap enforces conflicts"), }; - store.append_writeup_version(&id, &body)?; + let (title, body) = editor::parse_ticket_edit(&body)?; + store.set_writeup_title(&id, &title)?; + let appended = body.is_some(); + if let Some(body) = body { + store.append_writeup_version(&id, &body)?; + } let writeup = store.load_writeup(&id)?; - println!( - "Appended version {} to writeup {}.", - writeup.versions.len(), - writeup.short_id() - ); + if appended { + println!( + "Appended version {} to writeup {}.", + writeup.versions.len(), + writeup.short_id() + ); + } else { + println!("Updated writeup {}.", writeup.short_id()); + } Ok(()) } @@ -265,6 +287,9 @@ fn print_writeup(writeup: &Writeup, all: bool) -> Result<()> { println!("- Id: `{}`", writeup.id); println!("- Short id: `{}`", writeup.short_id()); println!("- Status: `{}`", writeup.status.as_str()); + if let Some(priority) = writeup.priority { + println!("- Priority: `{priority}`"); + } println!( "- Created: `{}` by {}", writeup.created_at.format(&Rfc3339)?, @@ -313,6 +338,15 @@ fn print_writeup(writeup: &Writeup, all: bool) -> Result<()> { Ok(()) } +fn writeup_edit_body(writeup: &Writeup) -> String { + let mut body = writeup.title.clone(); + if let Some(latest_body) = writeup.latest_body() { + body.push_str("\n\n"); + body.push_str(latest_body); + } + body +} + fn body_from_args( body: Option, file: Option, diff --git a/crates/ticgit/tests/cli.rs b/crates/ticgit/tests/cli.rs index 7a31d50f..71c67536 100644 --- a/crates/ticgit/tests/cli.rs +++ b/crates/ticgit/tests/cli.rs @@ -1202,6 +1202,46 @@ fn writeup_workflow_creates_versions_links_and_promotes() { .stdout(predicate::str::contains("Rethink sync")); } +#[cfg(unix)] +#[test] +fn writeup_edit_editor_uses_first_line_as_title() { + let repo = TestRepo::new(); + let output = repo + .ti() + .args([ + "writeup", + "new", + "--title", + "Original title", + "--body", + "Original body", + "--id-only", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let writeup = String::from_utf8(output).unwrap().trim().to_string(); + let writeup_prefix = &writeup[..6]; + let editor = editor_script(&repo, "Updated title\n\nUpdated body"); + + repo.ti() + .env("EDITOR", editor) + .args(["writeup", "edit", writeup_prefix]) + .assert() + .success(); + + repo.ti() + .args(["writeup", "show", writeup_prefix]) + .assert() + .success() + .stdout(predicate::str::contains("# Writeup: Updated title")) + .stdout(predicate::str::contains("Updated body")) + .stdout(predicate::str::contains("Updated title\n\nUpdated title").not()) + .stdout(predicate::str::contains("Original body").not()); +} + #[test] fn list_search_filters_title_description_and_comments() { let repo = TestRepo::new();