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