Skip to main content

gitv_tui/ui/components/
issue_create.rs

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