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