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 },
33 layout::Layout,
34 toast_action,
35 utils::get_border_style,
36 },
37};
38use anyhow::anyhow;
39use ratatui_toaster::ToastType;
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}