2052d029
Add inline issue creation from TUI dashboard
a73x 2026-03-21 08:59
Press 'n' to enter a two-step creation form (title then body) rendered in the footer. Refactors search_active bool into InputMode enum (Normal/Search/CreateTitle/CreateBody) for cleaner state management. Escape cancels at any step, empty titles are rejected. Includes 3 new unit tests. Fixes: 708f4122 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/tui.rs b/src/tui.rs index 3c5fbdb..1df8bb8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -10,6 +10,7 @@ use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; use crate::error::Error; use crate::issue as issue_mod; use crate::patch as patch_mod; use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; @@ -56,6 +57,14 @@ impl StatusFilter { } } #[derive(Debug, PartialEq)] enum InputMode { Normal, Search, CreateTitle, CreateBody, } struct App { tab: Tab, issues: Vec<IssueState>, @@ -67,7 +76,9 @@ struct App { mode: ViewMode, status_filter: StatusFilter, search_query: String, search_active: bool, input_mode: InputMode, input_buf: String, create_title: String, status_msg: Option<String>, } @@ -88,7 +99,9 @@ impl App { mode: ViewMode::Details, status_filter: StatusFilter::Open, search_query: String::new(), search_active: false, input_mode: InputMode::Normal, input_buf: String::new(), create_title: String::new(), status_msg: None, } } @@ -305,31 +318,109 @@ fn run_loop( if let Event::Key(key) = event::read()? { app.status_msg = None; // clear status on any keypress // Search mode intercept — handle keys before normal bindings if app.search_active { match key.code { KeyCode::Esc => { app.search_active = false; app.search_query.clear(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); // Input mode intercept — handle keys before normal bindings match app.input_mode { InputMode::Search => { match key.code { KeyCode::Esc => { app.input_mode = InputMode::Normal; app.search_query.clear(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Backspace => { app.search_query.pop(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Char(c) => { app.search_query.push(c); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); } _ => {} } KeyCode::Backspace => { app.search_query.pop(); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); continue; } InputMode::CreateTitle => { match key.code { KeyCode::Esc => { app.input_mode = InputMode::Normal; app.input_buf.clear(); } KeyCode::Enter => { let title = app.input_buf.trim().to_string(); if title.is_empty() { app.input_mode = InputMode::Normal; app.input_buf.clear(); } else { app.create_title = title; app.input_buf.clear(); app.input_mode = InputMode::CreateBody; } } KeyCode::Backspace => { app.input_buf.pop(); } KeyCode::Char(c) => { app.input_buf.push(c); } _ => {} } KeyCode::Char(c) => { app.search_query.push(c); let count = app.visible_count(); app.list_state .select(if count > 0 { Some(0) } else { None }); continue; } InputMode::CreateBody => { match key.code { KeyCode::Esc => { // Submit with title only, no body let title = app.create_title.clone(); match issue_mod::open(repo, &title, "") { Ok(id) => { app.reload(repo); app.status_msg = Some(format!("Issue created: {:.8}", id)); } Err(e) => { app.status_msg = Some(format!("Error creating issue: {}", e)); } } app.input_mode = InputMode::Normal; app.input_buf.clear(); app.create_title.clear(); } KeyCode::Enter => { let title = app.create_title.clone(); let body = app.input_buf.clone(); match issue_mod::open(repo, &title, &body) { Ok(id) => { app.reload(repo); app.status_msg = Some(format!("Issue created: {:.8}", id)); } Err(e) => { app.status_msg = Some(format!("Error creating issue: {}", e)); } } app.input_mode = InputMode::Normal; app.input_buf.clear(); app.create_title.clear(); } KeyCode::Backspace => { app.input_buf.pop(); } KeyCode::Char(c) => { app.input_buf.push(c); } _ => {} } _ => {} continue; } continue; InputMode::Normal => {} } match key.code { @@ -338,9 +429,17 @@ fn run_loop( return Ok(()) } KeyCode::Char('/') => { app.search_active = true; app.input_mode = InputMode::Search; app.search_query.clear(); } KeyCode::Char('n') => { if app.tab != Tab::Issues { app.switch_tab(Tab::Issues); } app.input_mode = InputMode::CreateTitle; app.input_buf.clear(); app.create_title.clear(); } KeyCode::Char('1') => app.switch_tab(Tab::Issues), KeyCode::Char('2') => app.switch_tab(Tab::Patches), KeyCode::Char('j') | KeyCode::Down => { @@ -1024,18 +1123,47 @@ fn colorize_diff(diff: &str, inline_comments: &[state::InlineComment]) -> Text<' } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { if app.search_active { let max_query_len = (area.width as usize).saturating_sub(12); let display_query = if app.search_query.len() > max_query_len { &app.search_query[app.search_query.len() - max_query_len..] } else { &app.search_query }; let text = format!(" Search: {}_", display_query); let style = Style::default().bg(Color::Blue).fg(Color::White); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; match app.input_mode { InputMode::Search => { let max_query_len = (area.width as usize).saturating_sub(12); let display_query = if app.search_query.len() > max_query_len { &app.search_query[app.search_query.len() - max_query_len..] } else { &app.search_query }; let text = format!(" Search: {}_", display_query); let style = Style::default().bg(Color::Blue).fg(Color::White); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::CreateTitle => { let max_len = (area.width as usize).saturating_sub(22); let display = if app.input_buf.len() > max_len { &app.input_buf[app.input_buf.len() - max_len..] } else { &app.input_buf }; let text = format!(" New issue - Title: {}_", display); let style = Style::default().bg(Color::Green).fg(Color::Black); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::CreateBody => { let max_len = (area.width as usize).saturating_sub(21); let display = if app.input_buf.len() > max_len { &app.input_buf[app.input_buf.len() - max_len..] } else { &app.input_buf }; let text = format!(" New issue - Body: {}_ (Esc: skip)", display); let style = Style::default().bg(Color::Green).fg(Color::Black); let para = Paragraph::new(text).style(style); frame.render_widget(para, area); return; } InputMode::Normal => {} } let mode_hint = if app.tab == Tab::Patches { @@ -1055,7 +1183,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { format!(" {}", msg) } else { format!( " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search g:follow o:checkout r:refresh q:quit", " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit", filter_hint, mode_hint ) }; @@ -1209,15 +1337,63 @@ mod tests { fn test_escape_clears_text_preserves_status() { let mut app = test_app(); app.status_filter = StatusFilter::Closed; app.search_active = true; app.input_mode = InputMode::Search; app.search_query = "some query".into(); // Simulate Escape app.search_active = false; app.input_mode = InputMode::Normal; app.search_query.clear(); assert_eq!(app.status_filter, StatusFilter::Closed); assert!(!app.search_active); assert_eq!(app.input_mode, InputMode::Normal); assert!(app.search_query.is_empty()); } #[test] fn test_create_title_mode_clears_on_escape() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = "partial title".into(); // Simulate Escape app.input_mode = InputMode::Normal; app.input_buf.clear(); assert_eq!(app.input_mode, InputMode::Normal); assert!(app.input_buf.is_empty()); } #[test] fn test_create_title_transitions_to_body() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = "My new issue".into(); // Simulate Enter with non-empty title let title = app.input_buf.trim().to_string(); assert!(!title.is_empty()); app.create_title = title; app.input_buf.clear(); app.input_mode = InputMode::CreateBody; assert_eq!(app.input_mode, InputMode::CreateBody); assert_eq!(app.create_title, "My new issue"); assert!(app.input_buf.is_empty()); } #[test] fn test_empty_title_dismissed() { let mut app = test_app(); app.input_mode = InputMode::CreateTitle; app.input_buf = " ".into(); // Simulate Enter with whitespace-only title let title = app.input_buf.trim().to_string(); if title.is_empty() { app.input_mode = InputMode::Normal; app.input_buf.clear(); } assert_eq!(app.input_mode, InputMode::Normal); } }