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}