a73x

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);
    }
}