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