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}