Skip to main content

gitv_tui/ui/components/
issue_create.rs

1use async_trait::async_trait;
2use crossterm::event;
3use octocrab::models::issues::Issue;
4use rat_cursor::HasScreenCursor;
5use rat_widget::{
6    event::{HandleEvent, TextOutcome, ct_event},
7    focus::{FocusBuilder, FocusFlag, HasFocus, Navigation},
8    paragraph::{Paragraph, ParagraphState},
9    text_input::{TextInput, TextInputState},
10    textarea::{TextArea, TextAreaState, TextWrap},
11};
12use ratatui::{
13    buffer::Buffer,
14    layout::Rect,
15    style::{Color, Style},
16    widgets::{Block, StatefulWidget},
17};
18use ratatui_macros::vertical;
19use throbber_widgets_tui::{BRAILLE_SIX_DOUBLE, Throbber, ThrobberState, WhichUse};
20
21use crate::{
22    app::GITHUB_CLIENT,
23    errors::AppError,
24    ui::{
25        Action, AppState,
26        components::{
27            Component,
28            help::HelpElementKind,
29            issue_conversation::{IssueConversationSeed, render_markdown_lines},
30            issue_detail::IssuePreviewSeed,
31            issue_list::MainScreen,
32        },
33        layout::Layout,
34        utils::get_border_style,
35    },
36};
37use anyhow::anyhow;
38
39pub const HELP: &[HelpElementKind] = &[
40    crate::help_text!("Issue Create Help"),
41    crate::help_keybind!("n", "open new issue composer (from issue list)"),
42    crate::help_keybind!("Tab / Shift+Tab", "switch fields"),
43    crate::help_keybind!("Ctrl+P", "toggle body input and markdown preview"),
44    crate::help_keybind!("Ctrl+Enter / Alt+Enter", "create issue"),
45    crate::help_keybind!("Esc", "return to issue list"),
46];
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49enum InputMode {
50    #[default]
51    Input,
52    Preview,
53}
54
55impl InputMode {
56    fn toggle(&mut self) {
57        *self = match self {
58            Self::Input => Self::Preview,
59            Self::Preview => Self::Input,
60        };
61    }
62}
63
64pub struct IssueCreate {
65    action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
66    owner: String,
67    repo: String,
68    screen: MainScreen,
69    focus: FocusFlag,
70    area: Rect,
71    index: usize,
72    title_state: TextInputState,
73    labels_state: TextInputState,
74    assignees_state: TextInputState,
75    body_state: TextAreaState,
76    preview_state: ParagraphState,
77    mode: InputMode,
78    creating: bool,
79    create_throbber_state: ThrobberState,
80    error: Option<String>,
81    preview_cache_input: String,
82    preview_cache_width: usize,
83    preview_cache: Vec<ratatui::text::Line<'static>>,
84}
85
86impl IssueCreate {
87    pub fn new(AppState { owner, repo, .. }: AppState) -> Self {
88        Self {
89            action_tx: None,
90            owner,
91            repo,
92            screen: MainScreen::List,
93            focus: FocusFlag::new().with_name("issue_create"),
94            area: Rect::default(),
95            index: 0,
96            title_state: TextInputState::default(),
97            labels_state: TextInputState::default(),
98            assignees_state: TextInputState::default(),
99            body_state: TextAreaState::new(),
100            preview_state: ParagraphState::default(),
101            mode: InputMode::default(),
102            creating: false,
103            create_throbber_state: ThrobberState::default(),
104            error: None,
105            preview_cache_input: String::new(),
106            preview_cache_width: 0,
107            preview_cache: Vec::new(),
108        }
109    }
110
111    fn reset_form(&mut self) {
112        self.title_state.set_text("");
113        self.labels_state.set_text("");
114        self.assignees_state.set_text("");
115        self.body_state.set_text("");
116        self.error = None;
117        self.mode = InputMode::Input;
118        self.preview_state.focus.set(false);
119        self.title_state.focus.set(true);
120        self.labels_state.focus.set(false);
121        self.assignees_state.focus.set(false);
122        self.body_state.focus.set(false);
123        self.preview_cache_input.clear();
124        self.preview_cache.clear();
125        self.preview_cache_width = 0;
126    }
127
128    fn parse_csv(input: &str) -> Option<Vec<String>> {
129        let values = input
130            .split(',')
131            .map(str::trim)
132            .filter(|s| !s.is_empty())
133            .map(ToOwned::to_owned)
134            .collect::<Vec<_>>();
135        if values.is_empty() {
136            None
137        } else {
138            Some(values)
139        }
140    }
141
142    fn body_preview_lines(&mut self, width: usize) -> &[ratatui::text::Line<'static>] {
143        let body = self.body_state.text();
144        if self.preview_cache_width != width || self.preview_cache_input != body {
145            self.preview_cache_width = width;
146            self.preview_cache_input.clear();
147            self.preview_cache_input.push_str(&body);
148            self.preview_cache = render_markdown_lines(&self.preview_cache_input, width, 2);
149        }
150        self.preview_cache.as_slice()
151    }
152
153    async fn submit(&mut self) {
154        if self.creating {
155            return;
156        }
157        let title = self.title_state.text().trim().to_string();
158        if title.is_empty() {
159            self.error = Some("Title cannot be empty.".to_string());
160            return;
161        }
162
163        let body = self.body_state.text().trim().to_string();
164        let labels = Self::parse_csv(self.labels_state.text());
165        let assignees = Self::parse_csv(self.assignees_state.text());
166
167        let Some(action_tx) = self.action_tx.clone() else {
168            return;
169        };
170        let owner = self.owner.clone();
171        let repo = self.repo.clone();
172        self.creating = true;
173        self.error = None;
174
175        tokio::spawn(async move {
176            let Some(client) = GITHUB_CLIENT.get() else {
177                let _ = action_tx
178                    .send(Action::IssueCreateError {
179                        message: "GitHub client not initialized.".to_string(),
180                    })
181                    .await;
182                return;
183            };
184            let issues = client.inner().issues(owner, repo);
185            let mut create = issues.create(title);
186            if !body.is_empty() {
187                create = create.body(body);
188            }
189            if let Some(labels) = labels {
190                create = create.labels(labels);
191            }
192            if let Some(assignees) = assignees {
193                create = create.assignees(assignees);
194            }
195
196            match create.send().await {
197                Ok(issue) => {
198                    let _ = action_tx
199                        .send(Action::IssueCreateSuccess {
200                            issue: Box::new(issue),
201                        })
202                        .await;
203                }
204                Err(err) => {
205                    let _ = action_tx
206                        .send(Action::IssueCreateError {
207                            message: err.to_string().replace('\n', " "),
208                        })
209                        .await;
210                }
211            }
212        });
213    }
214
215    async fn handle_create_success(&mut self, issue: Issue) {
216        self.creating = false;
217        self.error = None;
218        let Some(action_tx) = self.action_tx.clone() else {
219            return;
220        };
221        let number = issue.number;
222        let labels = issue.labels.clone();
223        let preview_seed = IssuePreviewSeed::from_issue(&issue);
224        let conversation_seed = IssueConversationSeed::from_issue(&issue);
225        self.reset_form();
226        let _ = action_tx
227            .send(Action::SelectedIssue { number, labels })
228            .await;
229        let _ = action_tx
230            .send(Action::SelectedIssuePreview { seed: preview_seed })
231            .await;
232        let _ = action_tx
233            .send(Action::EnterIssueDetails {
234                seed: conversation_seed,
235            })
236            .await;
237        let _ = action_tx
238            .send(Action::ChangeIssueScreen(MainScreen::Details))
239            .await;
240    }
241
242    pub fn render(&mut self, area: Layout, buf: &mut Buffer) {
243        self.area = area.main_content;
244        let [title_area, labels_area, assignees_area, body_area] = vertical![==3, ==3, ==3, *=1]
245            .areas(
246                area.main_content
247                    .union(area.text_search.union(area.label_search)),
248            );
249
250        let title_input = TextInput::new().block(
251            Block::bordered()
252                .border_type(ratatui::widgets::BorderType::Rounded)
253                .border_style(get_border_style(&self.title_state))
254                .title(format!("[{}] Title", self.index)),
255        );
256        title_input.render(title_area, buf, &mut self.title_state);
257
258        let labels_input = TextInput::new().block(
259            Block::bordered()
260                .border_type(ratatui::widgets::BorderType::Rounded)
261                .border_style(get_border_style(&self.labels_state))
262                .title("Labels (comma-separated)"),
263        );
264        labels_input.render(labels_area, buf, &mut self.labels_state);
265
266        let assignees_input = TextInput::new().block(
267            Block::bordered()
268                .border_type(ratatui::widgets::BorderType::Rounded)
269                .border_style(get_border_style(&self.assignees_state))
270                .title("Assignees (comma-separated)"),
271        );
272        assignees_input.render(assignees_area, buf, &mut self.assignees_state);
273
274        match self.mode {
275            InputMode::Input => {
276                let mut title = "Body (Ctrl+P: Preview | Ctrl+Enter: Create)".to_string();
277                if let Some(err) = &self.error {
278                    title.push_str(" | ");
279                    title.push_str(err);
280                }
281                let mut block = Block::bordered()
282                    .border_type(ratatui::widgets::BorderType::Rounded)
283                    .border_style(get_border_style(&self.body_state));
284                if !self.creating {
285                    block = block.title(title);
286                }
287                let textarea = TextArea::new().block(block).text_wrap(TextWrap::Word(4));
288                textarea.render(body_area, buf, &mut self.body_state);
289            }
290            InputMode::Preview => {
291                let mut title = "Preview (Ctrl+P: Edit | Ctrl+Enter: Create)".to_string();
292                if let Some(err) = &self.error {
293                    title.push_str(" | ");
294                    title.push_str(err);
295                }
296                let preview_width = body_area.width.saturating_sub(4).max(10) as usize;
297                let lines = self.body_preview_lines(preview_width).to_vec();
298                let preview = Paragraph::new(lines)
299                    .block(
300                        Block::bordered()
301                            .border_type(ratatui::widgets::BorderType::Rounded)
302                            .border_style(get_border_style(&self.preview_state))
303                            .title(title),
304                    )
305                    .focus_style(Style::default())
306                    .hide_focus(true)
307                    .wrap(ratatui::widgets::Wrap { trim: false });
308                preview.render(body_area, buf, &mut self.preview_state);
309            }
310        }
311
312        if self.creating {
313            let title_area = Rect {
314                x: body_area.x + 1,
315                y: body_area.y,
316                width: 10,
317                height: 1,
318            };
319            let throbber = Throbber::default()
320                .label("Creating")
321                .style(Style::new().fg(Color::Cyan))
322                .throbber_set(BRAILLE_SIX_DOUBLE)
323                .use_type(WhichUse::Spin);
324            StatefulWidget::render(throbber, title_area, buf, &mut self.create_throbber_state);
325        }
326    }
327}
328
329#[async_trait(?Send)]
330impl Component for IssueCreate {
331    fn render(&mut self, area: Layout, buf: &mut Buffer) {
332        self.render(area, buf);
333    }
334
335    fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
336        self.action_tx = Some(action_tx);
337    }
338
339    async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
340        match event {
341            Action::AppEvent(ref event) => {
342                if self.screen != MainScreen::CreateIssue {
343                    return Ok(());
344                }
345                match event {
346                    ct_event!(keycode press Esc) => {
347                        if let Some(action_tx) = self.action_tx.clone() {
348                            let _ = action_tx
349                                .send(Action::ChangeIssueScreen(MainScreen::List))
350                                .await;
351                        }
352                        return Ok(());
353                    }
354                    ct_event!(key press CONTROL-'p') => {
355                        self.mode.toggle();
356                        match self.mode {
357                            InputMode::Input => {
358                                self.preview_state.focus.set(false);
359                                self.body_state.focus.set(true);
360                            }
361                            InputMode::Preview => {
362                                self.body_state.focus.set(false);
363                                self.preview_state.focus.set(true);
364                            }
365                        }
366                        return Ok(());
367                    }
368                    ct_event!(keycode press CONTROL-Enter) | ct_event!(keycode press ALT-Enter) => {
369                        self.submit().await;
370                        return Ok(());
371                    }
372                    ct_event!(keycode press Tab) | ct_event!(keycode press SHIFT-Tab)
373                        if self.body_state.is_focused() =>
374                    {
375                        if let Some(action_tx) = self.action_tx.clone() {
376                            let _ = action_tx.send(Action::ForceFocusChange).await;
377                        }
378                        return Ok(());
379                    }
380                    _ => {}
381                }
382
383                self.title_state.handle(event, rat_widget::event::Regular);
384                self.labels_state.handle(event, rat_widget::event::Regular);
385                self.assignees_state
386                    .handle(event, rat_widget::event::Regular);
387
388                if matches!(
389                    event,
390                    ct_event!(keycode press Up)
391                        | ct_event!(keycode press Down)
392                        | ct_event!(keycode press Left)
393                        | ct_event!(keycode press Right)
394                ) {
395                    let action_tx = self.action_tx.as_ref().ok_or_else(|| {
396                        AppError::Other(anyhow!("issue create action channel unavailable"))
397                    })?;
398                    action_tx
399                        .send(Action::ForceRender)
400                        .await
401                        .map_err(|_| AppError::TokioMpsc)?;
402                }
403                match self.mode {
404                    InputMode::Input => {
405                        if let event::Event::Key(key) = event
406                            && key.code == event::KeyCode::Tab
407                        {
408                            return Ok(());
409                        }
410                        if let event::Event::Paste(pasted_stuff) = event {
411                            self.body_state.insert_str(pasted_stuff);
412                        }
413                        let o = self.body_state.handle(event, rat_widget::event::Regular);
414                        if o == TextOutcome::TextChanged {
415                            let action_tx = self.action_tx.as_ref().ok_or_else(|| {
416                                AppError::Other(anyhow!("issue create action channel unavailable"))
417                            })?;
418                            action_tx
419                                .send(Action::ForceRender)
420                                .await
421                                .map_err(|_| AppError::TokioMpsc)?;
422                        }
423                    }
424                    InputMode::Preview => {
425                        self.preview_state.handle(event, rat_widget::event::Regular);
426                    }
427                }
428            }
429            Action::Tick => {
430                if self.creating {
431                    self.create_throbber_state.calc_next();
432                }
433            }
434            Action::EnterIssueCreate => {
435                self.screen = MainScreen::CreateIssue;
436                self.reset_form();
437            }
438            Action::IssueCreateSuccess { issue } => {
439                if self.screen == MainScreen::CreateIssue {
440                    self.handle_create_success(*issue).await;
441                }
442            }
443            Action::IssueCreateError { message } => {
444                self.creating = false;
445                if self.screen == MainScreen::CreateIssue {
446                    self.error = Some(message);
447                }
448            }
449            Action::ChangeIssueScreen(screen) => {
450                self.screen = screen;
451                if screen != MainScreen::CreateIssue {
452                    self.title_state.focus.set(false);
453                    self.labels_state.focus.set(false);
454                    self.assignees_state.focus.set(false);
455                    self.body_state.focus.set(false);
456                    self.preview_state.focus.set(false);
457                }
458            }
459            _ => {}
460        }
461        Ok(())
462    }
463
464    fn cursor(&self) -> Option<(u16, u16)> {
465        self.title_state
466            .screen_cursor()
467            .or_else(|| self.labels_state.screen_cursor())
468            .or_else(|| self.assignees_state.screen_cursor())
469            .or_else(|| self.body_state.screen_cursor())
470    }
471
472    fn should_render(&self) -> bool {
473        self.screen == MainScreen::CreateIssue
474    }
475
476    fn is_animating(&self) -> bool {
477        self.screen == MainScreen::CreateIssue && self.creating
478    }
479
480    fn capture_focus_event(&self, event: &event::Event) -> bool {
481        if self.screen != MainScreen::CreateIssue {
482            return false;
483        }
484        if !(self.title_state.is_focused()
485            || self.labels_state.is_focused()
486            || self.assignees_state.is_focused()
487            || self.body_state.is_focused())
488        {
489            return false;
490        }
491        if self.body_state.is_focused()
492            && !matches!(
493                event,
494                ct_event!(keycode press Tab) | ct_event!(keycode press SHIFT-Tab)
495            )
496        {
497            return true;
498        }
499        match event {
500            event::Event::Key(key) => matches!(
501                key.code,
502                event::KeyCode::Char('q') | event::KeyCode::Tab | event::KeyCode::BackTab
503            ),
504            _ => false,
505        }
506    }
507
508    fn set_index(&mut self, index: usize) {
509        self.index = index;
510    }
511
512    fn set_global_help(&self) {
513        if let Some(action_tx) = &self.action_tx {
514            let _ = action_tx.try_send(Action::SetHelp(HELP));
515        }
516    }
517}
518
519impl HasFocus for IssueCreate {
520    fn build(&self, builder: &mut FocusBuilder) {
521        let tag = builder.start(self);
522        builder.widget(&self.title_state);
523        builder.widget(&self.labels_state);
524        builder.widget(&self.assignees_state);
525        match self.mode {
526            InputMode::Input => builder.widget(&self.body_state),
527            InputMode::Preview => builder.widget(&self.preview_state),
528        };
529        builder.end(tag);
530    }
531
532    fn focus(&self) -> FocusFlag {
533        self.focus.clone()
534    }
535
536    fn area(&self) -> Rect {
537        self.area
538    }
539
540    fn navigable(&self) -> Navigation {
541        if self.screen == MainScreen::CreateIssue {
542            Navigation::Regular
543        } else {
544            Navigation::None
545        }
546    }
547}