max / makenotwork
4 files changed,
+554 insertions,
-4 deletions
| @@ -73,4 +73,90 @@ pub enum Command { | |||
| 73 | 73 | #[arg(long)] | |
| 74 | 74 | peer: Vec<String>, | |
| 75 | 75 | }, | |
| 76 | + | /// Show ticket aggregates: open by priority/source + avg resolution time | |
| 77 | + | Stats, | |
| 78 | + | /// Dump tickets as JSON or CSV | |
| 79 | + | Export { | |
| 80 | + | /// Output format | |
| 81 | + | #[arg(long, default_value = "json")] | |
| 82 | + | format: ExportFormat, | |
| 83 | + | /// Output file. Defaults to stdout. | |
| 84 | + | #[arg(short, long)] | |
| 85 | + | output: Option<std::path::PathBuf>, | |
| 86 | + | }, | |
| 87 | + | /// Delete tickets matching the given status that have been untouched for | |
| 88 | + | /// at least `--older-than`. Duration format: `90d`, `12h`, `30m`. | |
| 89 | + | Prune { | |
| 90 | + | /// Age threshold (e.g. `90d`, `12h`, `30m`) | |
| 91 | + | #[arg(long)] | |
| 92 | + | older_than: String, | |
| 93 | + | /// Status to prune (defaults to closed) | |
| 94 | + | #[arg(long, default_value = "closed")] | |
| 95 | + | status: Status, | |
| 96 | + | /// Print what would be deleted without modifying the database | |
| 97 | + | #[arg(long)] | |
| 98 | + | dry_run: bool, | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[derive(Clone, Copy, Debug, clap::ValueEnum)] | |
| 103 | + | pub enum ExportFormat { | |
| 104 | + | Json, | |
| 105 | + | Csv, | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Parse a duration string like `90d`, `12h`, `30m` into `chrono::Duration`. | |
| 109 | + | pub fn parse_duration(s: &str) -> Result<chrono::Duration, String> { | |
| 110 | + | let s = s.trim(); | |
| 111 | + | if s.is_empty() { | |
| 112 | + | return Err("duration is empty".to_string()); | |
| 113 | + | } | |
| 114 | + | let (num, unit) = s.split_at(s.len() - 1); | |
| 115 | + | let n: i64 = num | |
| 116 | + | .parse() | |
| 117 | + | .map_err(|_| format!("invalid duration number in '{s}'"))?; | |
| 118 | + | if n < 0 { | |
| 119 | + | return Err(format!("duration must be non-negative: '{s}'")); | |
| 120 | + | } | |
| 121 | + | match unit { | |
| 122 | + | "d" => Ok(chrono::Duration::days(n)), | |
| 123 | + | "h" => Ok(chrono::Duration::hours(n)), | |
| 124 | + | "m" => Ok(chrono::Duration::minutes(n)), | |
| 125 | + | other => Err(format!("unknown duration unit '{other}' (use d/h/m)")), | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[cfg(test)] | |
| 130 | + | mod tests { | |
| 131 | + | use super::*; | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn parse_duration_days() { | |
| 135 | + | assert_eq!(parse_duration("90d").unwrap(), chrono::Duration::days(90)); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | #[test] | |
| 139 | + | fn parse_duration_hours() { | |
| 140 | + | assert_eq!(parse_duration("12h").unwrap(), chrono::Duration::hours(12)); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn parse_duration_minutes() { | |
| 145 | + | assert_eq!(parse_duration("30m").unwrap(), chrono::Duration::minutes(30)); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn parse_duration_rejects_unknown_unit() { | |
| 150 | + | assert!(parse_duration("5y").is_err()); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn parse_duration_rejects_negative() { | |
| 155 | + | assert!(parse_duration("-1d").is_err()); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[test] | |
| 159 | + | fn parse_duration_rejects_empty() { | |
| 160 | + | assert!(parse_duration("").is_err()); | |
| 161 | + | } | |
| 76 | 162 | } |
| @@ -239,6 +239,102 @@ pub fn update_status(conn: &Connection, id: &str, status: Status) -> Result<()> | |||
| 239 | 239 | Ok(()) | |
| 240 | 240 | } | |
| 241 | 241 | ||
| 242 | + | /// Update a ticket's title and body. Bumps `updated_at`. | |
| 243 | + | pub fn update_ticket( | |
| 244 | + | conn: &Connection, | |
| 245 | + | id: &str, | |
| 246 | + | title: &str, | |
| 247 | + | body: Option<&str>, | |
| 248 | + | ) -> Result<()> { | |
| 249 | + | let now = Utc::now().to_rfc3339(); | |
| 250 | + | let rows = conn.execute( | |
| 251 | + | "UPDATE tickets SET title = ?1, body = ?2, updated_at = ?3 WHERE id = ?4", | |
| 252 | + | params![title, body, now, id], | |
| 253 | + | )?; | |
| 254 | + | if rows == 0 { | |
| 255 | + | return Err(eyre!("no ticket with id '{id}'")); | |
| 256 | + | } | |
| 257 | + | Ok(()) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// Aggregate stats across all tickets. | |
| 261 | + | #[derive(Debug, Default)] | |
| 262 | + | pub struct Stats { | |
| 263 | + | pub total: usize, | |
| 264 | + | pub by_status: Vec<(Status, usize)>, | |
| 265 | + | pub open_by_priority: Vec<(Priority, usize)>, | |
| 266 | + | pub open_by_source: Vec<(String, usize)>, | |
| 267 | + | /// Average seconds between `created_at` and `resolved_at` for tickets that | |
| 268 | + | /// were resolved. `None` when nothing has been resolved yet. | |
| 269 | + | pub avg_resolution_seconds: Option<i64>, | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | pub fn stats(conn: &Connection) -> Result<Stats> { | |
| 273 | + | let tickets = list_tickets(conn, &ListFilter::default())?; | |
| 274 | + | let mut s = Stats { total: tickets.len(), ..Default::default() }; | |
| 275 | + | ||
| 276 | + | let order_status = [Status::Open, Status::InProgress, Status::Resolved, Status::Closed]; | |
| 277 | + | for status in order_status { | |
| 278 | + | let count = tickets.iter().filter(|t| t.status == status).count(); | |
| 279 | + | if count > 0 { | |
| 280 | + | s.by_status.push((status, count)); | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | let order_pri = [Priority::Critical, Priority::High, Priority::Medium, Priority::Low]; | |
| 285 | + | for pri in order_pri { | |
| 286 | + | let count = tickets | |
| 287 | + | .iter() | |
| 288 | + | .filter(|t| t.status == Status::Open && t.priority == pri) | |
| 289 | + | .count(); | |
| 290 | + | if count > 0 { | |
| 291 | + | s.open_by_priority.push((pri, count)); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | let mut by_source: std::collections::HashMap<String, usize> = | |
| 296 | + | std::collections::HashMap::new(); | |
| 297 | + | for t in tickets.iter().filter(|t| t.status == Status::Open) { | |
| 298 | + | let key = t.source.clone().unwrap_or_else(|| "-".to_string()); | |
| 299 | + | *by_source.entry(key).or_insert(0) += 1; | |
| 300 | + | } | |
| 301 | + | let mut by_source: Vec<(String, usize)> = by_source.into_iter().collect(); | |
| 302 | + | by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); | |
| 303 | + | s.open_by_source = by_source; | |
| 304 | + | ||
| 305 | + | let resolved: Vec<&Ticket> = tickets | |
| 306 | + | .iter() | |
| 307 | + | .filter(|t| t.resolved_at.is_some()) | |
| 308 | + | .collect(); | |
| 309 | + | if !resolved.is_empty() { | |
| 310 | + | let total: i64 = resolved | |
| 311 | + | .iter() | |
| 312 | + | .map(|t| { | |
| 313 | + | let resolved_at = t.resolved_at.unwrap(); | |
| 314 | + | (resolved_at - t.created_at).num_seconds().max(0) | |
| 315 | + | }) | |
| 316 | + | .sum(); | |
| 317 | + | s.avg_resolution_seconds = Some(total / resolved.len() as i64); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | Ok(s) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// Delete tickets older than `older_than` with the given status. | |
| 324 | + | /// Returns the number of rows deleted. | |
| 325 | + | pub fn prune_tickets( | |
| 326 | + | conn: &Connection, | |
| 327 | + | older_than: chrono::Duration, | |
| 328 | + | status: Status, | |
| 329 | + | ) -> Result<usize> { | |
| 330 | + | let cutoff = (Utc::now() - older_than).to_rfc3339(); | |
| 331 | + | let rows = conn.execute( | |
| 332 | + | "DELETE FROM tickets WHERE status = ?1 AND updated_at < ?2", | |
| 333 | + | params![status.to_string(), cutoff], | |
| 334 | + | )?; | |
| 335 | + | Ok(rows) | |
| 336 | + | } | |
| 337 | + | ||
| 242 | 338 | // -- Sync operations ---------------------------------------------------------- | |
| 243 | 339 | ||
| 244 | 340 | /// Get all tickets updated after the given timestamp. | |
| @@ -449,6 +545,85 @@ mod tests { | |||
| 449 | 545 | } | |
| 450 | 546 | ||
| 451 | 547 | #[test] | |
| 548 | + | fn update_ticket_changes_fields() { | |
| 549 | + | let conn = open_memory().unwrap(); | |
| 550 | + | let node = get_or_create_node_id(&conn).unwrap(); | |
| 551 | + | let t = create_ticket(&conn, &test_new_ticket("orig"), &node).unwrap(); | |
| 552 | + | ||
| 553 | + | update_ticket(&conn, &t.id, "renamed", Some("with body")).unwrap(); | |
| 554 | + | let fetched = get_ticket(&conn, &t.id).unwrap(); | |
| 555 | + | assert_eq!(fetched.title, "renamed"); | |
| 556 | + | assert_eq!(fetched.body.as_deref(), Some("with body")); | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | #[test] | |
| 560 | + | fn update_ticket_missing_errors() { | |
| 561 | + | let conn = open_memory().unwrap(); | |
| 562 | + | assert!(update_ticket(&conn, "no-such-id", "x", None).is_err()); | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | #[test] | |
| 566 | + | fn stats_aggregates() { | |
| 567 | + | let conn = open_memory().unwrap(); | |
| 568 | + | let node = get_or_create_node_id(&conn).unwrap(); | |
| 569 | + | let a = create_ticket(&conn, &NewTicket { | |
| 570 | + | title: "a".into(), body: None, priority: Priority::Critical, | |
| 571 | + | channel: Channel::System, source: Some("pom".into()), source_ref: None, | |
| 572 | + | }, &node).unwrap(); | |
| 573 | + | create_ticket(&conn, &NewTicket { | |
| 574 | + | title: "b".into(), body: None, priority: Priority::Low, | |
| 575 | + | channel: Channel::Task, source: Some("manual".into()), source_ref: None, | |
| 576 | + | }, &node).unwrap(); | |
| 577 | + | let c = create_ticket(&conn, &test_new_ticket("c"), &node).unwrap(); | |
| 578 | + | update_status(&conn, &c.id, Status::Resolved).unwrap(); | |
| 579 | + | ||
| 580 | + | let s = stats(&conn).unwrap(); | |
| 581 | + | assert_eq!(s.total, 3); | |
| 582 | + | assert!(s.by_status.iter().any(|(st, n)| *st == Status::Open && *n == 2)); | |
| 583 | + | assert!(s.by_status.iter().any(|(st, n)| *st == Status::Resolved && *n == 1)); | |
| 584 | + | // Two open tickets: Critical and Low | |
| 585 | + | assert!(s.open_by_priority.iter().any(|(p, n)| *p == Priority::Critical && *n == 1)); | |
| 586 | + | assert!(s.open_by_priority.iter().any(|(p, n)| *p == Priority::Low && *n == 1)); | |
| 587 | + | // Source counts only the open tickets | |
| 588 | + | let pom = s.open_by_source.iter().find(|(k, _)| k == "pom").map(|(_, n)| *n); | |
| 589 | + | assert_eq!(pom, Some(1)); | |
| 590 | + | assert!(s.avg_resolution_seconds.is_some()); | |
| 591 | + | // First ticket is still open | |
| 592 | + | let _ = a; | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | #[test] | |
| 596 | + | fn prune_deletes_matching() { | |
| 597 | + | let conn = open_memory().unwrap(); | |
| 598 | + | let node = get_or_create_node_id(&conn).unwrap(); | |
| 599 | + | let closed = create_ticket(&conn, &test_new_ticket("old closed"), &node).unwrap(); | |
| 600 | + | update_status(&conn, &closed.id, Status::Closed).unwrap(); | |
| 601 | + | // Backdate updated_at by 100 days | |
| 602 | + | let backdate = (Utc::now() - chrono::Duration::days(100)).to_rfc3339(); | |
| 603 | + | conn.execute( | |
| 604 | + | "UPDATE tickets SET updated_at = ?1 WHERE id = ?2", | |
| 605 | + | params![backdate, closed.id], | |
| 606 | + | ).unwrap(); | |
| 607 | + | ||
| 608 | + | // A recent closed ticket should not be pruned | |
| 609 | + | let recent = create_ticket(&conn, &test_new_ticket("recent closed"), &node).unwrap(); | |
| 610 | + | update_status(&conn, &recent.id, Status::Closed).unwrap(); | |
| 611 | + | ||
| 612 | + | // An old but still-open ticket should not be pruned | |
| 613 | + | let open = create_ticket(&conn, &test_new_ticket("old open"), &node).unwrap(); | |
| 614 | + | conn.execute( | |
| 615 | + | "UPDATE tickets SET updated_at = ?1 WHERE id = ?2", | |
| 616 | + | params![backdate, open.id], | |
| 617 | + | ).unwrap(); | |
| 618 | + | ||
| 619 | + | let n = prune_tickets(&conn, chrono::Duration::days(90), Status::Closed).unwrap(); | |
| 620 | + | assert_eq!(n, 1); | |
| 621 | + | assert!(get_ticket(&conn, &closed.id).is_err()); | |
| 622 | + | assert!(get_ticket(&conn, &recent.id).is_ok()); | |
| 623 | + | assert!(get_ticket(&conn, &open.id).is_ok()); | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | #[test] | |
| 452 | 627 | fn node_id_persists() { | |
| 453 | 628 | let conn = open_memory().unwrap(); | |
| 454 | 629 | let id1 = get_or_create_node_id(&conn).unwrap(); |
| @@ -4,9 +4,11 @@ mod db; | |||
| 4 | 4 | mod tui; | |
| 5 | 5 | mod types; | |
| 6 | 6 | ||
| 7 | + | use std::io::Write; | |
| 8 | + | ||
| 7 | 9 | use clap::Parser; | |
| 8 | - | use cli::Command; | |
| 9 | - | use color_eyre::eyre::Result; | |
| 10 | + | use cli::{Command, ExportFormat}; | |
| 11 | + | use color_eyre::eyre::{Result, WrapErr}; | |
| 10 | 12 | use db::ListFilter; | |
| 11 | 13 | use types::{NewTicket, Status}; | |
| 12 | 14 | ||
| @@ -97,11 +99,138 @@ fn main() -> Result<()> { | |||
| 97 | 99 | let rt = tokio::runtime::Runtime::new()?; | |
| 98 | 100 | rt.block_on(api::serve(conn, port, peer))?; | |
| 99 | 101 | } | |
| 102 | + | ||
| 103 | + | Some(Command::Stats) => print_stats(&conn)?, | |
| 104 | + | ||
| 105 | + | Some(Command::Export { format, output }) => { | |
| 106 | + | let tickets = db::list_tickets(&conn, &ListFilter::default())?; | |
| 107 | + | let mut writer: Box<dyn Write> = match output { | |
| 108 | + | Some(path) => Box::new( | |
| 109 | + | std::fs::File::create(&path) | |
| 110 | + | .wrap_err_with(|| format!("create {}", path.display()))?, | |
| 111 | + | ), | |
| 112 | + | None => Box::new(std::io::stdout().lock()), | |
| 113 | + | }; | |
| 114 | + | match format { | |
| 115 | + | ExportFormat::Json => { | |
| 116 | + | serde_json::to_writer_pretty(&mut writer, &tickets)?; | |
| 117 | + | writeln!(writer)?; | |
| 118 | + | } | |
| 119 | + | ExportFormat::Csv => write_csv(&mut writer, &tickets)?, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | Some(Command::Prune { older_than, status, dry_run }) => { | |
| 124 | + | let dur = cli::parse_duration(&older_than) | |
| 125 | + | .map_err(|e| color_eyre::eyre::eyre!(e))?; | |
| 126 | + | if dry_run { | |
| 127 | + | let cutoff = chrono::Utc::now() - dur; | |
| 128 | + | let candidates: Vec<_> = db::list_tickets(&conn, &ListFilter { | |
| 129 | + | status: Some(status), | |
| 130 | + | ..Default::default() | |
| 131 | + | })? | |
| 132 | + | .into_iter() | |
| 133 | + | .filter(|t| t.updated_at < cutoff) | |
| 134 | + | .collect(); | |
| 135 | + | if candidates.is_empty() { | |
| 136 | + | println!("no tickets to prune"); | |
| 137 | + | } else { | |
| 138 | + | for t in &candidates { | |
| 139 | + | println!("{} {} ({})", t.short_id(), t.title, t.age()); | |
| 140 | + | } | |
| 141 | + | println!("\nwould delete {} ticket(s)", candidates.len()); | |
| 142 | + | } | |
| 143 | + | } else { | |
| 144 | + | let n = db::prune_tickets(&conn, dur, status)?; | |
| 145 | + | println!("pruned {n} ticket(s)"); | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | Ok(()) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | fn print_stats(conn: &rusqlite::Connection) -> Result<()> { | |
| 154 | + | let s = db::stats(conn)?; | |
| 155 | + | println!("Total: {}", s.total); | |
| 156 | + | ||
| 157 | + | if !s.by_status.is_empty() { | |
| 158 | + | println!("\nBy status:"); | |
| 159 | + | for (st, n) in &s.by_status { | |
| 160 | + | println!(" {st:<12} {n}"); | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | if !s.open_by_priority.is_empty() { | |
| 165 | + | println!("\nOpen by priority:"); | |
| 166 | + | for (p, n) in &s.open_by_priority { | |
| 167 | + | println!(" {p:<9} {n}"); | |
| 168 | + | } | |
| 100 | 169 | } | |
| 101 | 170 | ||
| 171 | + | if !s.open_by_source.is_empty() { | |
| 172 | + | println!("\nOpen by source:"); | |
| 173 | + | for (src, n) in &s.open_by_source { | |
| 174 | + | println!(" {src:<20} {n}"); | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | if let Some(avg) = s.avg_resolution_seconds { | |
| 179 | + | println!("\nAverage resolution time: {}", format_duration(avg)); | |
| 180 | + | } else { | |
| 181 | + | println!("\nAverage resolution time: -"); | |
| 182 | + | } | |
| 183 | + | Ok(()) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | fn format_duration(secs: i64) -> String { | |
| 187 | + | let d = secs / 86_400; | |
| 188 | + | let h = (secs % 86_400) / 3_600; | |
| 189 | + | let m = (secs % 3_600) / 60; | |
| 190 | + | if d > 0 { | |
| 191 | + | format!("{d}d {h}h") | |
| 192 | + | } else if h > 0 { | |
| 193 | + | format!("{h}h {m}m") | |
| 194 | + | } else { | |
| 195 | + | format!("{m}m") | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | fn write_csv<W: Write>(w: &mut W, tickets: &[types::Ticket]) -> Result<()> { | |
| 200 | + | writeln!( | |
| 201 | + | w, | |
| 202 | + | "id,title,body,priority,status,channel,node_id,source,source_ref,created_at,updated_at,resolved_at" | |
| 203 | + | )?; | |
| 204 | + | for t in tickets { | |
| 205 | + | writeln!( | |
| 206 | + | w, | |
| 207 | + | "{},{},{},{},{},{},{},{},{},{},{},{}", | |
| 208 | + | csv_escape(&t.id), | |
| 209 | + | csv_escape(&t.title), | |
| 210 | + | csv_escape(t.body.as_deref().unwrap_or("")), | |
| 211 | + | t.priority, | |
| 212 | + | t.status, | |
| 213 | + | t.channel, | |
| 214 | + | csv_escape(&t.node_id), | |
| 215 | + | csv_escape(t.source.as_deref().unwrap_or("")), | |
| 216 | + | csv_escape(t.source_ref.as_deref().unwrap_or("")), | |
| 217 | + | t.created_at.to_rfc3339(), | |
| 218 | + | t.updated_at.to_rfc3339(), | |
| 219 | + | t.resolved_at.map(|d| d.to_rfc3339()).unwrap_or_default(), | |
| 220 | + | )?; | |
| 221 | + | } | |
| 102 | 222 | Ok(()) | |
| 103 | 223 | } | |
| 104 | 224 | ||
| 225 | + | fn csv_escape(s: &str) -> String { | |
| 226 | + | if s.contains(',') || s.contains('"') || s.contains('\n') { | |
| 227 | + | let escaped = s.replace('"', "\"\""); | |
| 228 | + | format!("\"{escaped}\"") | |
| 229 | + | } else { | |
| 230 | + | s.to_string() | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 105 | 234 | fn truncate(s: &str, max: usize) -> String { | |
| 106 | 235 | if s.len() <= max { | |
| 107 | 236 | s.to_string() |
| @@ -20,6 +20,20 @@ enum View { | |||
| 20 | 20 | List, | |
| 21 | 21 | Detail, | |
| 22 | 22 | Create, | |
| 23 | + | Edit, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 27 | + | enum EditFocus { | |
| 28 | + | Title, | |
| 29 | + | Body, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | struct EditBuf { | |
| 33 | + | id: String, | |
| 34 | + | title: String, | |
| 35 | + | body: String, | |
| 36 | + | focus: EditFocus, | |
| 23 | 37 | } | |
| 24 | 38 | ||
| 25 | 39 | enum InputMode { | |
| @@ -43,6 +57,7 @@ struct App { | |||
| 43 | 57 | all_sources: Vec<String>, | |
| 44 | 58 | search_query: String, | |
| 45 | 59 | create_input: String, | |
| 60 | + | edit_buf: Option<EditBuf>, | |
| 46 | 61 | running: bool, | |
| 47 | 62 | } | |
| 48 | 63 | ||
| @@ -62,6 +77,7 @@ impl App { | |||
| 62 | 77 | all_sources: Vec::new(), | |
| 63 | 78 | search_query: String::new(), | |
| 64 | 79 | create_input: String::new(), | |
| 80 | + | edit_buf: None, | |
| 65 | 81 | running: true, | |
| 66 | 82 | }; | |
| 67 | 83 | app.reload_sources()?; | |
| @@ -174,6 +190,31 @@ impl App { | |||
| 174 | 190 | self.refresh() | |
| 175 | 191 | } | |
| 176 | 192 | ||
| 193 | + | fn enter_edit(&mut self) { | |
| 194 | + | if let Some(t) = self.selected_ticket() { | |
| 195 | + | self.edit_buf = Some(EditBuf { | |
| 196 | + | id: t.id.clone(), | |
| 197 | + | title: t.title.clone(), | |
| 198 | + | body: t.body.clone().unwrap_or_default(), | |
| 199 | + | focus: EditFocus::Title, | |
| 200 | + | }); | |
| 201 | + | self.view = View::Edit; | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn submit_edit(&mut self) -> Result<()> { | |
| 206 | + | if let Some(buf) = self.edit_buf.take() { | |
| 207 | + | let title = buf.title.trim(); | |
| 208 | + | if !title.is_empty() { | |
| 209 | + | let body = if buf.body.is_empty() { None } else { Some(buf.body.as_str()) }; | |
| 210 | + | db::update_ticket(&self.conn, &buf.id, title, body)?; | |
| 211 | + | self.refresh()?; | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | self.view = View::Detail; | |
| 215 | + | Ok(()) | |
| 216 | + | } | |
| 217 | + | ||
| 177 | 218 | fn submit_create(&mut self) -> Result<()> { | |
| 178 | 219 | let title = self.create_input.trim().to_string(); | |
| 179 | 220 | if !title.is_empty() { | |
| @@ -262,6 +303,52 @@ fn handle_key(app: &mut App, key: KeyCode) -> Result<()> { | |||
| 262 | 303 | return Ok(()); | |
| 263 | 304 | } | |
| 264 | 305 | ||
| 306 | + | // Edit input mode | |
| 307 | + | if matches!(app.view, View::Edit) { | |
| 308 | + | match key { | |
| 309 | + | KeyCode::Esc => { | |
| 310 | + | app.edit_buf = None; | |
| 311 | + | app.view = View::Detail; | |
| 312 | + | } | |
| 313 | + | KeyCode::Tab | KeyCode::BackTab => { | |
| 314 | + | if let Some(buf) = app.edit_buf.as_mut() { | |
| 315 | + | buf.focus = match buf.focus { | |
| 316 | + | EditFocus::Title => EditFocus::Body, | |
| 317 | + | EditFocus::Body => EditFocus::Title, | |
| 318 | + | }; | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | KeyCode::Enter => { | |
| 322 | + | if let Some(buf) = app.edit_buf.as_mut() { | |
| 323 | + | match buf.focus { | |
| 324 | + | EditFocus::Title => buf.focus = EditFocus::Body, | |
| 325 | + | EditFocus::Body => { | |
| 326 | + | app.submit_edit()?; | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | KeyCode::Backspace => { | |
| 332 | + | if let Some(buf) = app.edit_buf.as_mut() { | |
| 333 | + | match buf.focus { | |
| 334 | + | EditFocus::Title => { buf.title.pop(); } | |
| 335 | + | EditFocus::Body => { buf.body.pop(); } | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | KeyCode::Char(c) => { | |
| 340 | + | if let Some(buf) = app.edit_buf.as_mut() { | |
| 341 | + | match buf.focus { | |
| 342 | + | EditFocus::Title => buf.title.push(c), | |
| 343 | + | EditFocus::Body => buf.body.push(c), | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | _ => {} | |
| 348 | + | } | |
| 349 | + | return Ok(()); | |
| 350 | + | } | |
| 351 | + | ||
| 265 | 352 | match app.view { | |
| 266 | 353 | View::List => match key { | |
| 267 | 354 | KeyCode::Char('q') => app.running = false, | |
| @@ -304,9 +391,10 @@ fn handle_key(app: &mut App, key: KeyCode) -> Result<()> { | |||
| 304 | 391 | KeyCode::Char('i') => { app.set_selected_status(Status::InProgress)?; } | |
| 305 | 392 | KeyCode::Char('r') => { app.set_selected_status(Status::Resolved)?; } | |
| 306 | 393 | KeyCode::Char('c') => { app.set_selected_status(Status::Closed)?; } | |
| 394 | + | KeyCode::Char('e') => app.enter_edit(), | |
| 307 | 395 | _ => {} | |
| 308 | 396 | }, | |
| 309 | - | View::Create => {} // handled above | |
| 397 | + | View::Create | View::Edit => {} // handled above | |
| 310 | 398 | } | |
| 311 | 399 | ||
| 312 | 400 | Ok(()) | |
| @@ -331,6 +419,10 @@ fn render(app: &mut App, f: &mut Frame) { | |||
| 331 | 419 | render_list(app, f, main_area); | |
| 332 | 420 | render_create_popup(app, f, f.area()); | |
| 333 | 421 | } | |
| 422 | + | View::Edit => { | |
| 423 | + | render_detail(app, f, main_area); | |
| 424 | + | render_edit_popup(app, f, f.area()); | |
| 425 | + | } | |
| 334 | 426 | } | |
| 335 | 427 | ||
| 336 | 428 | render_status_bar(app, f, status_area); | |
| @@ -518,14 +610,82 @@ fn render_create_popup(app: &App, f: &mut Frame, area: Rect) { | |||
| 518 | 610 | f.render_widget(Paragraph::new(lines), inner); | |
| 519 | 611 | } | |
| 520 | 612 | ||
| 613 | + | fn render_edit_popup(app: &App, f: &mut Frame, area: Rect) { | |
| 614 | + | let buf = match app.edit_buf.as_ref() { | |
| 615 | + | Some(b) => b, | |
| 616 | + | None => return, | |
| 617 | + | }; | |
| 618 | + | ||
| 619 | + | let popup_width = 70.min(area.width.saturating_sub(4)); | |
| 620 | + | let popup_height = 10.min(area.height.saturating_sub(4)); | |
| 621 | + | let popup_area = Rect { | |
| 622 | + | x: (area.width.saturating_sub(popup_width)) / 2, | |
| 623 | + | y: (area.height.saturating_sub(popup_height)) / 2, | |
| 624 | + | width: popup_width, | |
| 625 | + | height: popup_height, | |
| 626 | + | }; | |
| 627 | + | ||
| 628 | + | f.render_widget(Clear, popup_area); | |
| 629 | + | ||
| 630 | + | let block = Block::default() | |
| 631 | + | .borders(Borders::ALL) | |
| 632 | + | .title(" Edit Ticket "); | |
| 633 | + | let inner = block.inner(popup_area); | |
| 634 | + | f.render_widget(block, popup_area); | |
| 635 | + | ||
| 636 | + | let title_focused = buf.focus == EditFocus::Title; | |
| 637 | + | let body_focused = buf.focus == EditFocus::Body; | |
| 638 | + | let cursor = "_"; | |
| 639 | + | ||
| 640 | + | let mut lines = vec![ | |
| 641 | + | Line::from(vec![ | |
| 642 | + | Span::styled(if title_focused { "> Title: " } else { " Title: " }, | |
| 643 | + | Style::new().bold()), | |
| 644 | + | Span::styled(&buf.title, Style::new().underlined()), | |
| 645 | + | if title_focused { | |
| 646 | + | Span::styled(cursor, Style::new().rapid_blink()) | |
| 647 | + | } else { | |
| 648 | + | Span::raw("") | |
| 649 | + | }, | |
| 650 | + | ]), | |
| 651 | + | Line::raw(""), | |
| 652 | + | Line::from(vec![ | |
| 653 | + | Span::styled(if body_focused { "> Body: " } else { " Body: " }, | |
| 654 | + | Style::new().bold()), | |
| 655 | + | ]), | |
| 656 | + | ]; | |
| 657 | + | let body_display = if buf.body.is_empty() && !body_focused { | |
| 658 | + | Line::styled(" (empty)", Style::new().dim()) | |
| 659 | + | } else { | |
| 660 | + | Line::from(vec![ | |
| 661 | + | Span::raw(" "), | |
| 662 | + | Span::raw(&buf.body), | |
| 663 | + | if body_focused { | |
| 664 | + | Span::styled(cursor, Style::new().rapid_blink()) | |
| 665 | + | } else { | |
| 666 | + | Span::raw("") | |
| 667 | + | }, | |
| 668 | + | ]) | |
| 669 | + | }; | |
| 670 | + | lines.push(body_display); | |
| 671 | + | lines.push(Line::raw("")); | |
| 672 | + | lines.push(Line::styled( | |
| 673 | + | "Tab: switch field Enter: next/save Esc: cancel", | |
| 674 | + | Style::new().dim(), | |
| 675 | + | )); | |
| 676 | + | ||
| 677 | + | f.render_widget(Paragraph::new(lines), inner); | |
| 678 | + | } | |
| 679 | + | ||
| 521 | 680 | fn render_status_bar(app: &App, f: &mut Frame, area: Rect) { | |
| 522 | 681 | let hints = match app.view { | |
| 523 | 682 | View::List => match app.input_mode { | |
| 524 | 683 | InputMode::Search => "Enter: apply Esc: clear Type to search", | |
| 525 | 684 | InputMode::Normal => "j/k:nav Enter:open n:new o/i/r/c:status f:status p:pri t:channel s:src /:search q:quit", | |
| 526 | 685 | }, | |
| 527 | - | View::Detail => "o/i/r/c:status Esc:back", | |
| 686 | + | View::Detail => "o/i/r/c:status e:edit Esc:back", | |
| 528 | 687 | View::Create => "Enter:create Esc:cancel", | |
| 688 | + | View::Edit => "Tab:switch Enter:next/save Esc:cancel", | |
| 529 | 689 | }; | |
| 530 | 690 | f.render_widget( | |
| 531 | 691 | Line::from(Span::styled(format!(" {hints}"), Style::new().dim())), |