Skip to main content

max / makenotwork

22.0 KB · 695 lines History Blame Raw
1 //! Ratatui TUI for interactive ticket management.
2
3 use color_eyre::eyre::Result;
4 use crossterm::event::{self, Event, KeyCode, KeyEventKind};
5 use ratatui::{
6 Frame,
7 layout::{Constraint, Layout, Rect},
8 style::{Modifier, Style, Stylize},
9 text::{Line, Span},
10 widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState},
11 };
12 use rusqlite::Connection;
13
14 use crate::db::{self, ListFilter};
15 use crate::types::{Channel, Priority, Status, Ticket};
16
17 // -- View state ---------------------------------------------------------------
18
19 enum View {
20 List,
21 Detail,
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,
37 }
38
39 enum InputMode {
40 Normal,
41 Search,
42 }
43
44 // -- App ----------------------------------------------------------------------
45
46 struct App {
47 conn: Connection,
48 node_id: String,
49 tickets: Vec<Ticket>,
50 table_state: TableState,
51 view: View,
52 input_mode: InputMode,
53 status_filter: Option<Status>,
54 priority_filter: Option<Priority>,
55 channel_filter: Option<Channel>,
56 source_filter: Option<String>,
57 all_sources: Vec<String>,
58 search_query: String,
59 create_input: String,
60 edit_buf: Option<EditBuf>,
61 running: bool,
62 }
63
64 impl App {
65 fn new(conn: Connection, node_id: String) -> Result<Self> {
66 let mut app = Self {
67 conn,
68 node_id,
69 tickets: Vec::new(),
70 table_state: TableState::default(),
71 view: View::List,
72 input_mode: InputMode::Normal,
73 status_filter: None,
74 priority_filter: None,
75 channel_filter: None,
76 source_filter: None,
77 all_sources: Vec::new(),
78 search_query: String::new(),
79 create_input: String::new(),
80 edit_buf: None,
81 running: true,
82 };
83 app.reload_sources()?;
84 app.refresh()?;
85 if !app.tickets.is_empty() {
86 app.table_state.select(Some(0));
87 }
88 Ok(app)
89 }
90
91 fn refresh(&mut self) -> Result<()> {
92 let search = if self.search_query.is_empty() {
93 None
94 } else {
95 Some(self.search_query.as_str())
96 };
97 self.tickets = db::list_tickets(
98 &self.conn,
99 &ListFilter {
100 status: self.status_filter,
101 priority: self.priority_filter,
102 channel: self.channel_filter,
103 source: self.source_filter.as_deref(),
104 search,
105 },
106 )?;
107 // Keep selection in bounds
108 if self.tickets.is_empty() {
109 self.table_state.select(None);
110 } else if let Some(i) = self.table_state.selected() {
111 if i >= self.tickets.len() {
112 self.table_state.select(Some(self.tickets.len() - 1));
113 }
114 }
115 Ok(())
116 }
117
118 fn selected_ticket(&self) -> Option<&Ticket> {
119 self.table_state.selected().and_then(|i| self.tickets.get(i))
120 }
121
122 fn set_selected_status(&mut self, status: Status) -> Result<()> {
123 if let Some(ticket) = self.selected_ticket() {
124 let id = ticket.id.clone();
125 db::update_status(&self.conn, &id, status)?;
126 self.refresh()?;
127 }
128 Ok(())
129 }
130
131 fn cycle_status_filter(&mut self) -> Result<()> {
132 self.status_filter = match self.status_filter {
133 None => Some(Status::Open),
134 Some(Status::Open) => Some(Status::InProgress),
135 Some(Status::InProgress) => Some(Status::Resolved),
136 Some(Status::Resolved) => Some(Status::Closed),
137 Some(Status::Closed) => None,
138 };
139 self.refresh()
140 }
141
142 fn cycle_priority_filter(&mut self) -> Result<()> {
143 self.priority_filter = match self.priority_filter {
144 None => Some(Priority::Critical),
145 Some(Priority::Critical) => Some(Priority::High),
146 Some(Priority::High) => Some(Priority::Medium),
147 Some(Priority::Medium) => Some(Priority::Low),
148 Some(Priority::Low) => None,
149 };
150 self.refresh()
151 }
152
153 fn cycle_source_filter(&mut self) -> Result<()> {
154 self.reload_sources()?;
155 self.source_filter = match &self.source_filter {
156 None if !self.all_sources.is_empty() => Some(self.all_sources[0].clone()),
157 Some(current) => {
158 let idx = self.all_sources.iter().position(|s| s == current);
159 match idx {
160 Some(i) if i + 1 < self.all_sources.len() => {
161 Some(self.all_sources[i + 1].clone())
162 }
163 _ => None,
164 }
165 }
166 None => None,
167 };
168 self.refresh()
169 }
170
171 fn reload_sources(&mut self) -> Result<()> {
172 let all = db::list_tickets(&self.conn, &ListFilter::default())?;
173 let mut sources: Vec<String> = all
174 .iter()
175 .filter_map(|t| t.source.clone())
176 .collect();
177 sources.sort();
178 sources.dedup();
179 self.all_sources = sources;
180 Ok(())
181 }
182
183 fn cycle_channel_filter(&mut self) -> Result<()> {
184 self.channel_filter = match self.channel_filter {
185 None => Some(Channel::System),
186 Some(Channel::System) => Some(Channel::Request),
187 Some(Channel::Request) => Some(Channel::Task),
188 Some(Channel::Task) => None,
189 };
190 self.refresh()
191 }
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
218 fn submit_create(&mut self) -> Result<()> {
219 let title = self.create_input.trim().to_string();
220 if !title.is_empty() {
221 db::create_ticket(
222 &self.conn,
223 &crate::types::NewTicket {
224 title,
225 body: None,
226 priority: Priority::Medium,
227 channel: Channel::Task,
228 source: Some("manual".into()),
229 source_ref: None,
230 },
231 &self.node_id,
232 )?;
233 self.refresh()?;
234 }
235 self.create_input.clear();
236 self.view = View::List;
237 Ok(())
238 }
239 }
240
241 // -- Entry point --------------------------------------------------------------
242
243 pub fn run(conn: Connection, node_id: String) -> Result<()> {
244 let mut terminal = ratatui::init();
245 let mut app = App::new(conn, node_id)?;
246
247 while app.running {
248 terminal.draw(|f| render(&mut app, f))?;
249
250 if event::poll(std::time::Duration::from_millis(250))? {
251 if let Event::Key(key) = event::read()? {
252 if key.kind != KeyEventKind::Press {
253 continue;
254 }
255 handle_key(&mut app, key.code)?;
256 }
257 }
258 }
259
260 ratatui::restore();
261 Ok(())
262 }
263
264 // -- Input handling -----------------------------------------------------------
265
266 fn handle_key(app: &mut App, key: KeyCode) -> Result<()> {
267 // Search input mode captures all keys
268 if matches!(app.input_mode, InputMode::Search) {
269 match key {
270 KeyCode::Esc => {
271 app.search_query.clear();
272 app.input_mode = InputMode::Normal;
273 app.refresh()?;
274 }
275 KeyCode::Enter => {
276 app.input_mode = InputMode::Normal;
277 }
278 KeyCode::Backspace => {
279 app.search_query.pop();
280 app.refresh()?;
281 }
282 KeyCode::Char(c) => {
283 app.search_query.push(c);
284 app.refresh()?;
285 }
286 _ => {}
287 }
288 return Ok(());
289 }
290
291 // Create input mode
292 if matches!(app.view, View::Create) {
293 match key {
294 KeyCode::Esc => {
295 app.create_input.clear();
296 app.view = View::List;
297 }
298 KeyCode::Enter => app.submit_create()?,
299 KeyCode::Backspace => { app.create_input.pop(); }
300 KeyCode::Char(c) => app.create_input.push(c),
301 _ => {}
302 }
303 return Ok(());
304 }
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
352 match app.view {
353 View::List => match key {
354 KeyCode::Char('q') => app.running = false,
355 KeyCode::Char('j') | KeyCode::Down => {
356 let next = app.table_state.selected().map_or(0, |i| {
357 (i + 1).min(app.tickets.len().saturating_sub(1))
358 });
359 app.table_state.select(Some(next));
360 }
361 KeyCode::Char('k') | KeyCode::Up => {
362 let prev = app.table_state.selected().map_or(0, |i| i.saturating_sub(1));
363 app.table_state.select(Some(prev));
364 }
365 KeyCode::Enter => {
366 if app.selected_ticket().is_some() {
367 app.view = View::Detail;
368 }
369 }
370 KeyCode::Char('o') => app.set_selected_status(Status::Open)?,
371 KeyCode::Char('i') => app.set_selected_status(Status::InProgress)?,
372 KeyCode::Char('r') => app.set_selected_status(Status::Resolved)?,
373 KeyCode::Char('c') => app.set_selected_status(Status::Closed)?,
374 KeyCode::Char('n') => {
375 app.create_input.clear();
376 app.view = View::Create;
377 }
378 KeyCode::Char('f') => app.cycle_status_filter()?,
379 KeyCode::Char('p') => app.cycle_priority_filter()?,
380 KeyCode::Char('s') => app.cycle_source_filter()?,
381 KeyCode::Char('t') => app.cycle_channel_filter()?,
382 KeyCode::Char('/') => {
383 app.search_query.clear();
384 app.input_mode = InputMode::Search;
385 }
386 _ => {}
387 },
388 View::Detail => match key {
389 KeyCode::Esc | KeyCode::Char('q') => app.view = View::List,
390 KeyCode::Char('o') => { app.set_selected_status(Status::Open)?; }
391 KeyCode::Char('i') => { app.set_selected_status(Status::InProgress)?; }
392 KeyCode::Char('r') => { app.set_selected_status(Status::Resolved)?; }
393 KeyCode::Char('c') => { app.set_selected_status(Status::Closed)?; }
394 KeyCode::Char('e') => app.enter_edit(),
395 _ => {}
396 },
397 View::Create | View::Edit => {} // handled above
398 }
399
400 Ok(())
401 }
402
403 // -- Rendering ----------------------------------------------------------------
404
405 fn render(app: &mut App, f: &mut Frame) {
406 let [title_area, main_area, status_area] = Layout::vertical([
407 Constraint::Length(1),
408 Constraint::Fill(1),
409 Constraint::Length(1),
410 ])
411 .areas(f.area());
412
413 render_title_bar(app, f, title_area);
414
415 match app.view {
416 View::List => render_list(app, f, main_area),
417 View::Detail => render_detail(app, f, main_area),
418 View::Create => {
419 render_list(app, f, main_area);
420 render_create_popup(app, f, f.area());
421 }
422 View::Edit => {
423 render_detail(app, f, main_area);
424 render_edit_popup(app, f, f.area());
425 }
426 }
427
428 render_status_bar(app, f, status_area);
429 }
430
431 fn render_title_bar(app: &App, f: &mut Frame, area: Rect) {
432 let mut spans = vec![Span::styled(" WAM ", Style::new().bold().reversed())];
433
434 // Show active filters
435 let mut filters: Vec<String> = Vec::new();
436 if let Some(status) = app.status_filter {
437 filters.push(format!("status:{status}"));
438 }
439 if let Some(priority) = app.priority_filter {
440 filters.push(format!("pri:{priority}"));
441 }
442 if let Some(channel) = app.channel_filter {
443 filters.push(format!("ch:{channel}"));
444 }
445 if let Some(ref source) = app.source_filter {
446 filters.push(format!("src:{source}"));
447 }
448 if !filters.is_empty() {
449 spans.push(Span::raw(" "));
450 spans.push(Span::styled(filters.join(" "), Style::new().bold()));
451 }
452
453 if matches!(app.input_mode, InputMode::Search) {
454 spans.push(Span::raw(" /"));
455 spans.push(Span::styled(&app.search_query, Style::new().bold().underlined()));
456 } else if !app.search_query.is_empty() {
457 spans.push(Span::raw(" search: "));
458 spans.push(Span::styled(&app.search_query, Style::new().bold()));
459 }
460
461 let count = format!(" {} tickets ", app.tickets.len());
462 spans.push(Span::raw(count));
463
464 f.render_widget(Line::from(spans), area);
465 }
466
467 fn render_list(app: &mut App, f: &mut Frame, area: Rect) {
468 let header = Row::new(["Pri", "Title", "Ch", "Source", "Status", "Node", "Age"])
469 .style(Style::new().bold().underlined());
470
471 let rows: Vec<Row> = app
472 .tickets
473 .iter()
474 .map(|t| {
475 let pri_style = Style::new().fg(t.priority.color());
476 Row::new([
477 Cell::from(format!(" {} ", t.priority.to_string().chars().next().unwrap_or(' ')))
478 .style(pri_style),
479 Cell::from(t.title.as_str()),
480 Cell::from(t.channel.to_string()),
481 Cell::from(t.source.as_deref().unwrap_or("-")),
482 Cell::from(format!("{} {}", t.status.indicator(), t.status)),
483 Cell::from(t.short_node()),
484 Cell::from(t.age()),
485 ])
486 })
487 .collect();
488
489 let widths = [
490 Constraint::Length(5),
491 Constraint::Fill(1),
492 Constraint::Length(8),
493 Constraint::Length(14),
494 Constraint::Length(14),
495 Constraint::Length(10),
496 Constraint::Length(5),
497 ];
498
499 let table = Table::new(rows, widths)
500 .header(header)
501 .row_highlight_style(Style::new().add_modifier(Modifier::REVERSED));
502
503 f.render_stateful_widget(table, area, &mut app.table_state);
504 }
505
506 fn render_detail(app: &App, f: &mut Frame, area: Rect) {
507 let ticket = match app.selected_ticket() {
508 Some(t) => t,
509 None => {
510 f.render_widget(Paragraph::new("No ticket selected"), area);
511 return;
512 }
513 };
514
515 let mut lines = vec![
516 Line::from(vec![
517 Span::styled("ID: ", Style::new().bold()),
518 Span::raw(&ticket.id),
519 ]),
520 Line::from(vec![
521 Span::styled("Title: ", Style::new().bold()),
522 Span::raw(&ticket.title),
523 ]),
524 Line::from(vec![
525 Span::styled("Channel: ", Style::new().bold()),
526 Span::raw(ticket.channel.to_string()),
527 ]),
528 Line::from(vec![
529 Span::styled("Priority: ", Style::new().bold()),
530 Span::styled(ticket.priority.to_string(), Style::new().fg(ticket.priority.color())),
531 ]),
532 Line::from(vec![
533 Span::styled("Status: ", Style::new().bold()),
534 Span::raw(format!("{} {}", ticket.status.indicator(), ticket.status)),
535 ]),
536 Line::from(vec![
537 Span::styled("Node: ", Style::new().bold()),
538 Span::raw(&ticket.node_id),
539 ]),
540 Line::from(vec![
541 Span::styled("Source: ", Style::new().bold()),
542 Span::raw(ticket.source.as_deref().unwrap_or("-")),
543 ]),
544 Line::from(vec![
545 Span::styled("Ref: ", Style::new().bold()),
546 Span::raw(ticket.source_ref.as_deref().unwrap_or("-")),
547 ]),
548 Line::from(vec![
549 Span::styled("Created: ", Style::new().bold()),
550 Span::raw(ticket.created_at.format("%Y-%m-%d %H:%M UTC").to_string()),
551 ]),
552 Line::from(vec![
553 Span::styled("Updated: ", Style::new().bold()),
554 Span::raw(ticket.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()),
555 ]),
556 ];
557
558 if let Some(ref resolved) = ticket.resolved_at {
559 lines.push(Line::from(vec![
560 Span::styled("Resolved: ", Style::new().bold()),
561 Span::raw(resolved.format("%Y-%m-%d %H:%M UTC").to_string()),
562 ]));
563 }
564
565 lines.push(Line::raw(""));
566
567 if let Some(ref body) = ticket.body {
568 lines.push(Line::styled("--- Body ---", Style::new().bold()));
569 for line in body.lines() {
570 lines.push(Line::raw(line));
571 }
572 } else {
573 lines.push(Line::styled("(no body)", Style::new().dim()));
574 }
575
576 let block = Block::default()
577 .borders(Borders::ALL)
578 .title(" Ticket Detail ");
579 f.render_widget(Paragraph::new(lines).block(block), area);
580 }
581
582 fn render_create_popup(app: &App, f: &mut Frame, area: Rect) {
583 let popup_width = 50.min(area.width.saturating_sub(4));
584 let popup_height = 5;
585 let popup_area = Rect {
586 x: (area.width.saturating_sub(popup_width)) / 2,
587 y: (area.height.saturating_sub(popup_height)) / 2,
588 width: popup_width,
589 height: popup_height,
590 };
591
592 f.render_widget(Clear, popup_area);
593
594 let block = Block::default()
595 .borders(Borders::ALL)
596 .title(" New Ticket ");
597
598 let inner = block.inner(popup_area);
599 f.render_widget(block, popup_area);
600
601 let lines = vec![
602 Line::from(vec![
603 Span::raw("Title: "),
604 Span::styled(&app.create_input, Style::new().underlined()),
605 Span::styled("_", Style::new().rapid_blink()),
606 ]),
607 Line::raw(""),
608 Line::styled("Enter: create Esc: cancel", Style::new().dim()),
609 ];
610 f.render_widget(Paragraph::new(lines), inner);
611 }
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
680 fn render_status_bar(app: &App, f: &mut Frame, area: Rect) {
681 let hints = match app.view {
682 View::List => match app.input_mode {
683 InputMode::Search => "Enter: apply Esc: clear Type to search",
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",
685 },
686 View::Detail => "o/i/r/c:status e:edit Esc:back",
687 View::Create => "Enter:create Esc:cancel",
688 View::Edit => "Tab:switch Enter:next/save Esc:cancel",
689 };
690 f.render_widget(
691 Line::from(Span::styled(format!(" {hints}"), Style::new().dim())),
692 area,
693 );
694 }
695