1pub mod components;
2pub mod layout;
3pub mod macros;
4pub mod theme;
5pub mod utils;
6pub mod widgets;
7
8use crate::{
9 app::GITHUB_CLIENT,
10 define_cid_map,
11 errors::{AppError, Result},
12 ui::components::{
13 Component, DumbComponent,
14 help::HelpElementKind,
15 issue_conversation::IssueConversation,
16 issue_create::IssueCreate,
17 issue_detail::IssuePreview,
18 issue_list::{IssueList, MainScreen},
19 label_list::LabelList,
20 search_bar::TextSearch,
21 status_bar::StatusBar,
22 title_bar::TitleBar,
23 },
24};
25use crossterm::{
26 event::{
27 DisableBracketedPaste, EnableBracketedPaste, EventStream, KeyEvent,
28 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
29 },
30 execute,
31};
32use futures::{StreamExt, future::FutureExt};
33use octocrab::{
34 Page,
35 models::{Label, issues::Issue, reactions::ReactionContent},
36};
37use rat_widget::{
38 event::{HandleEvent, Outcome, Regular},
39 focus::{Focus, FocusBuilder, FocusFlag},
40};
41use ratatui::{
42 crossterm,
43 prelude::*,
44 widgets::{Block, Clear, Padding, Paragraph, Wrap},
45};
46use std::{
47 collections::HashMap,
48 fmt::Display,
49 io::stdout,
50 sync::{Arc, OnceLock},
51};
52use termprofile::{DetectorSettings, TermProfile};
53use tokio::{select, sync::mpsc::Sender};
54use tokio_util::sync::CancellationToken;
55use tracing::{error, info, instrument, trace};
56
57use anyhow::anyhow;
58
59use crate::ui::components::{
60 issue_conversation::{CommentView, IssueConversationSeed},
61 issue_detail::{IssuePreviewSeed, PrSummary},
62};
63
64const TICK_RATE: std::time::Duration = std::time::Duration::from_millis(100);
65pub static COLOR_PROFILE: OnceLock<TermProfile> = OnceLock::new();
66pub static CIDMAP: OnceLock<HashMap<u8, usize>> = OnceLock::new();
67const HELP_TEXT: &[HelpElementKind] = &[
68 crate::help_text!("Global Help"),
69 crate::help_text!(""),
70 crate::help_keybind!("1", "focus Search Bar"),
71 crate::help_keybind!("2", "focus Issue List"),
72 crate::help_keybind!("3", "focus Issue Conversation"),
73 crate::help_keybind!("4", "focus Label List"),
74 crate::help_keybind!("5", "focus Issue Create"),
75 crate::help_keybind!("q / Ctrl+C", "quit the application"),
76 crate::help_keybind!("? / Ctrl+H", "toggle help menu"),
77 crate::help_text!(""),
78 crate::help_text!(
79 "Navigate with the focus keys above. Components may have additional controls."
80 ),
81];
82
83pub async fn run(
84 AppState {
85 repo,
86 owner,
87 current_user,
88 }: AppState,
89) -> Result<(), AppError> {
90 if COLOR_PROFILE.get().is_none() {
91 COLOR_PROFILE
92 .set(TermProfile::detect(&stdout(), DetectorSettings::default()))
93 .map_err(|_| AppError::ErrorSettingGlobal("color profile"))?;
94 }
95 let mut terminal = ratatui::init();
96 setup_more_panic_hooks();
97 let (action_tx, action_rx) = tokio::sync::mpsc::channel(100);
98 let mut app = App::new(
99 action_tx,
100 action_rx,
101 AppState::new(repo, owner, current_user),
102 )
103 .await?;
104 let run_result = app.run(&mut terminal).await;
105 ratatui::restore();
106 finish_teardown()?;
107 run_result
108}
109
110struct App {
111 action_tx: tokio::sync::mpsc::Sender<Action>,
112 action_rx: tokio::sync::mpsc::Receiver<Action>,
113 focus: Option<Focus>,
114 cancel_action: CancellationToken,
115 components: Vec<Box<dyn Component>>,
116 dumb_components: Vec<Box<dyn DumbComponent>>,
117 help: Option<&'static [HelpElementKind]>,
118 in_help: bool,
119 in_editor: bool,
120 current_screen: MainScreen,
121 last_focused: Option<FocusFlag>,
122 last_event_error: Option<String>,
123}
124
125#[derive(Debug, Default, Clone)]
126pub struct AppState {
127 repo: String,
128 owner: String,
129 current_user: String,
130}
131
132impl AppState {
133 pub fn new(repo: String, owner: String, current_user: String) -> Self {
134 Self {
135 repo,
136 owner,
137 current_user,
138 }
139 }
140}
141
142fn focus(state: &mut App) -> Result<&mut Focus, AppError> {
143 focus_noret(state);
144 state
145 .focus
146 .as_mut()
147 .ok_or_else(|| AppError::Other(anyhow!("focus state was not initialized")))
148}
149
150fn focus_noret(state: &mut App) {
151 let mut f = FocusBuilder::new(state.focus.take());
152 for component in state.components.iter() {
153 if component.should_render() {
154 f.widget(component.as_ref());
155 }
156 }
157 state.focus = Some(f.build());
158}
159
160impl App {
161 fn capture_error(&mut self, err: impl Display) {
162 let message = err.to_string();
163 error!(error = %message, "captured ui error");
164 self.last_event_error = Some(message);
165 }
166
167 pub async fn new(
168 action_tx: Sender<Action>,
169 action_rx: tokio::sync::mpsc::Receiver<Action>,
170 state: AppState,
171 ) -> Result<Self, AppError> {
172 let mut text_search = TextSearch::new(state.clone());
173 let status_bar = StatusBar::new(state.clone());
174 let mut label_list = LabelList::new(state.clone());
175 let issue_preview = IssuePreview::new(state.clone());
176 let mut issue_conversation = IssueConversation::new(state.clone());
177 let mut issue_create = IssueCreate::new(state.clone());
178 let issue_handler = GITHUB_CLIENT
179 .get()
180 .ok_or_else(|| AppError::Other(anyhow!("github client is not initialized")))?
181 .inner()
182 .issues(state.owner.clone(), state.repo.clone());
183 let mut issue_list = IssueList::new(
184 issue_handler,
185 state.owner.clone(),
186 state.repo.clone(),
187 action_tx.clone(),
188 )
189 .await;
190
191 let comps = define_cid_map!(
192 2 -> issue_list,
193 3 -> issue_conversation,
194 5 -> issue_create,
195 4 -> label_list,
196 1 -> text_search, )?;
198 Ok(Self {
199 focus: None,
200 in_help: false,
201 in_editor: false,
202 current_screen: MainScreen::default(),
203 help: None,
204 action_tx,
205 action_rx,
206 last_focused: None,
207 last_event_error: None,
208 cancel_action: Default::default(),
209 components: comps,
210 dumb_components: vec![
211 Box::new(status_bar),
212 Box::new(issue_preview),
213 Box::new(TitleBar),
214 ],
215 })
216 }
217 pub async fn run(
218 &mut self,
219 terminal: &mut Terminal<CrosstermBackend<impl std::io::Write>>,
220 ) -> Result<(), AppError> {
221 let ctok = self.cancel_action.clone();
222 let action_tx = self.action_tx.clone();
223 for component in self.components.iter_mut() {
224 component.register_action_tx(action_tx.clone());
225 }
226
227 if let Err(err) = setup_terminal() {
228 self.capture_error(err);
229 }
230
231 tokio::spawn(async move {
232 let mut tick_interval = tokio::time::interval(TICK_RATE);
233 let mut event_stream = EventStream::new();
234
235 loop {
236 let event = select! {
237 _ = ctok.cancelled() => break,
238 _ = tick_interval.tick() => Action::Tick,
239 kevent = event_stream.next().fuse() => {
240 match kevent {
241 Some(Ok(kevent)) => Action::AppEvent(kevent),
242 Some(Err(..)) => Action::None,
243 None => break,
244 }
245 }
246 };
247 if action_tx.send(event).await.is_err() {
248 break;
249 }
250 }
251 Ok::<(), AppError>(())
252 });
253 focus_noret(self);
254 if let Some(ref mut focus) = self.focus {
255 if let Some(last) = self.components.last() {
256 focus.focus(&**last);
257 } else {
258 self.capture_error(anyhow!("no components available to focus"));
259 }
260 }
261 let ctok = self.cancel_action.clone();
262 loop {
263 let action = self.action_rx.recv().await;
264 let mut should_draw_error_popup = false;
265 let mut full_redraw = false;
266 if let Some(ref action) = action {
267 if let Action::EditorModeChanged(enabled) = action {
268 self.in_editor = *enabled;
269 if *enabled {
270 continue;
271 }
272 full_redraw = true;
273 }
274 if self.in_editor && matches!(action, Action::Tick | Action::AppEvent(_)) {
275 continue;
276 }
277 for component in self.components.iter_mut() {
278 if let Err(err) = component.handle_event(action.clone()).await {
279 let message = err.to_string();
280 error!(error = %message, "captured ui error");
281 self.last_event_error = Some(message);
282 should_draw_error_popup = true;
283 }
284 if component.gained_focus() && self.last_focused != Some(component.focus()) {
285 self.last_focused = Some(component.focus());
286 component.set_global_help();
287 }
288 }
289 for component in self.dumb_components.iter_mut() {
290 if let Err(err) = component.handle_event(action.clone()).await {
291 let message = err.to_string();
292 error!(error = %message, "captured ui error");
293 self.last_event_error = Some(message);
294 should_draw_error_popup = true;
295 }
296 }
297 }
298 let should_draw = match &action {
299 Some(Action::Tick) => self.has_animated_components(),
300 Some(Action::None) => false,
301 Some(Action::Quit) | None => false,
302 _ => true,
303 };
304 match action {
305 Some(Action::None) | Some(Action::Tick) => {}
306 Some(Action::ForceFocusChange) => match focus(self) {
307 Ok(focus) => {
308 let r = focus.next_force();
309 trace!(outcome = ?r, "Focus");
310 }
311 Err(err) => {
312 self.capture_error(err);
313 should_draw_error_popup = true;
314 }
315 },
316 Some(Action::ForceFocusChangeRev) => match focus(self) {
317 Ok(focus) => {
318 let r = focus.prev_force();
319 trace!(outcome = ?r, "Focus");
320 }
321 Err(err) => {
322 self.capture_error(err);
323 should_draw_error_popup = true;
324 }
325 },
326 Some(Action::AppEvent(ref event)) => {
327 info!(?event, "Received app event");
328 if let Err(err) = self.handle_event(event).await {
329 self.capture_error(err);
330 should_draw_error_popup = true;
331 }
332 }
333 Some(Action::SetHelp(help)) => {
334 self.help = Some(help);
335 }
336 Some(Action::EditorModeChanged(enabled)) => {
337 self.in_editor = enabled;
338 }
339 Some(Action::ChangeIssueScreen(screen)) => {
340 self.current_screen = screen;
341 focus_noret(self);
342 }
343 Some(Action::Quit) | None => {
344 ctok.cancel();
345
346 break;
347 }
348 _ => {}
349 }
350 if !self.in_editor
351 && (should_draw
352 || matches!(action, Some(Action::ForceRender))
353 || should_draw_error_popup)
354 {
355 if full_redraw && let Err(err) = terminal.clear() {
356 self.capture_error(err);
357 }
358 if let Err(err) = self.draw(terminal) {
359 self.capture_error(err);
360 }
361 }
362 if self.cancel_action.is_cancelled() {
363 break;
364 }
365 }
366
367 Ok(())
368 }
369 #[instrument(skip(self))]
370 async fn handle_event(&mut self, event: &crossterm::event::Event) -> Result<(), AppError> {
371 use crossterm::event::Event::Key;
372 use crossterm::event::KeyCode::*;
373 use rat_widget::event::ct_event;
374 trace!(?event, "Handling event");
375 if matches!(
376 event,
377 ct_event!(key press CONTROL-'c') | ct_event!(key press CONTROL-'q')
378 ) {
379 self.cancel_action.cancel();
380 return Ok(());
381 }
382 if self.last_event_error.is_some() {
383 if matches!(
384 event,
385 ct_event!(keycode press Esc) | ct_event!(keycode press Enter)
386 ) {
387 self.last_event_error = None;
388 }
389 return Ok(());
390 }
391 if matches!(event, ct_event!(key press CONTROL-'h')) {
392 self.in_help = !self.in_help;
393 self.help = Some(HELP_TEXT);
394 return Ok(());
395 }
396 if self.in_help && matches!(event, ct_event!(keycode press Esc)) {
397 self.in_help = false;
398 return Ok(());
399 }
400
401 let capture_focus = self
402 .components
403 .iter()
404 .any(|c| c.should_render() && c.capture_focus_event(event));
405 let focus = focus(self)?;
406 let outcome = focus.handle(event, Regular);
407 trace!(outcome = ?outcome, "Focus");
408 if let Outcome::Continue = outcome
409 && let Key(key) = event
410 && !capture_focus
411 {
412 self.handle_key(key).await?;
413 }
414 if let Key(key) = event {
415 match key.code {
416 Char(char)
417 if ('1'..='6').contains(&char)
418 && !self
419 .components
420 .iter()
421 .any(|c| c.should_render() && c.capture_focus_event(event)) =>
422 {
423 let index: u8 = char
425 .to_digit(10)
426 .ok_or_else(|| {
427 AppError::Other(anyhow!("failed to parse focus shortcut from key"))
428 })?
429 .try_into()
430 .map_err(|_| {
431 AppError::Other(anyhow!("focus shortcut is out of expected range"))
432 })?;
433 trace!("Focusing {}", index);
435 let cid_map = CIDMAP
436 .get()
437 .ok_or_else(|| AppError::ErrorSettingGlobal("component id map"))?;
438 let cid = cid_map.get(&index).ok_or_else(|| {
439 AppError::Other(anyhow!("component id {index} not found in focus map"))
440 })?;
441 let component = unsafe { self.components.get_unchecked(*cid) };
443
444 if let Some(f) = self.focus.as_mut() {
445 f.focus(component.as_ref());
446 }
447 }
448 _ => {}
449 }
450 }
451 Ok(())
452 }
453 async fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<(), AppError> {
454 use crossterm::event::KeyCode::*;
455 if matches!(key.code, Char('q'))
456 | matches!(
457 key,
458 KeyEvent {
459 code: Char('c' | 'q'),
460 modifiers: crossterm::event::KeyModifiers::CONTROL,
461 ..
462 }
463 )
464 {
465 self.cancel_action.cancel();
466 }
467 if matches!(key.code, Char('?')) {
468 self.in_help = !self.in_help;
469 }
470
471 Ok(())
472 }
473
474 fn has_animated_components(&self) -> bool {
475 self.components
476 .iter()
477 .any(|component| component.should_render() && component.is_animating())
478 }
479
480 fn draw(
481 &mut self,
482 terminal: &mut Terminal<CrosstermBackend<impl std::io::Write>>,
483 ) -> Result<(), AppError> {
484 terminal.draw(|f| {
485 let area = f.area();
486 let fullscreen = self.current_screen == MainScreen::DetailsFullscreen;
487 let layout = if fullscreen {
488 layout::Layout::fullscreen(area)
489 } else {
490 layout::Layout::new(area)
491 };
492 for component in self.components.iter() {
493 if component.should_render()
494 && let Some(p) = component.cursor()
495 {
496 f.set_cursor_position(p);
497 }
498 }
499 let buf = f.buffer_mut();
500
501 for component in self.components.iter_mut() {
502 if component.should_render() {
503 component.render(layout, buf);
504 }
505 }
506 if !fullscreen {
507 for component in self.dumb_components.iter_mut() {
508 component.render(layout, buf);
509 }
510 }
511 if self.in_help {
512 let help_text = self.help.unwrap_or(HELP_TEXT);
513 let help_component = components::help::HelpComponent::new(help_text)
514 .set_constraint(30)
515 .block(
516 Block::bordered()
517 .title("Help")
518 .padding(Padding::horizontal(2))
519 .border_type(ratatui::widgets::BorderType::Rounded),
520 );
521 help_component.render(area, buf);
522 }
523 if let Some(err) = self.last_event_error.as_ref() {
524 let popup_area = area.centered(Constraint::Percentage(60), Constraint::Length(5));
525 Clear.render(popup_area, buf);
526 let popup = Paragraph::new(err.as_str())
527 .wrap(Wrap { trim: false })
528 .block(
529 Block::bordered()
530 .title("Error")
531 .title_bottom("Esc/Enter: dismiss")
532 .padding(Padding::horizontal(1))
533 .border_type(ratatui::widgets::BorderType::Rounded),
534 );
535 popup.render(popup_area, buf);
536 }
537 })?;
538 Ok(())
539 }
540}
541
542#[derive(Debug, Clone)]
543#[non_exhaustive]
544pub enum Action {
545 None,
546 Tick,
547 Quit,
548 AppEvent(crossterm::event::Event),
549 RefreshIssueList,
550 NewPage(Arc<Page<Issue>>, MergeStrategy),
551 ForceRender,
552 SelectedIssue {
553 number: u64,
554 labels: Vec<Label>,
555 },
556 SelectedIssuePreview {
557 seed: IssuePreviewSeed,
558 },
559 IssuePreviewLoaded {
560 number: u64,
561 open_prs: Vec<PrSummary>,
562 },
563 IssuePreviewError {
564 number: u64,
565 message: String,
566 },
567 EnterIssueDetails {
568 seed: IssueConversationSeed,
569 },
570 IssueCommentsLoaded {
571 number: u64,
572 comments: Vec<CommentView>,
573 },
574 IssueReactionsLoaded {
575 reactions: HashMap<u64, Vec<(ReactionContent, u64)>>,
576 own_reactions: HashMap<u64, Vec<ReactionContent>>,
577 },
578 IssueReactionEditError {
579 comment_id: u64,
580 message: String,
581 },
582 IssueCommentPosted {
583 number: u64,
584 comment: CommentView,
585 },
586 IssueCommentsError {
587 number: u64,
588 message: String,
589 },
590 IssueCommentPostError {
591 number: u64,
592 message: String,
593 },
594 IssueCommentEditFinished {
595 issue_number: u64,
596 comment_id: u64,
597 result: std::result::Result<String, String>,
598 },
599 IssueCommentPatched {
600 issue_number: u64,
601 comment: CommentView,
602 },
603 EnterIssueCreate,
604 IssueCreateSuccess {
605 issue: Box<Issue>,
606 },
607 IssueCreateError {
608 message: String,
609 },
610 IssueCloseSuccess {
611 issue: Box<Issue>,
612 },
613 IssueCloseError {
614 number: u64,
615 message: String,
616 },
617 IssueLabelsUpdated {
618 number: u64,
619 labels: Vec<Label>,
620 },
621 LabelMissing {
622 name: String,
623 },
624 LabelEditError {
625 message: String,
626 },
627 LabelSearchPageAppend {
628 request_id: u64,
629 items: Vec<Label>,
630 scanned: u32,
631 matched: u32,
632 },
633 LabelSearchFinished {
634 request_id: u64,
635 scanned: u32,
636 matched: u32,
637 },
638 LabelSearchError {
639 request_id: u64,
640 message: String,
641 },
642 ChangeIssueScreen(MainScreen),
643 FinishedLoading,
644 ForceFocusChange,
645 ForceFocusChangeRev,
646 SetHelp(&'static [HelpElementKind]),
647 EditorModeChanged(bool),
648}
649
650#[derive(Debug, Clone)]
651pub enum MergeStrategy {
652 Append,
653 Replace,
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
657pub enum CloseIssueReason {
658 Completed,
659 NotPlanned,
660 Duplicate,
661}
662
663impl CloseIssueReason {
664 pub const ALL: [Self; 3] = [Self::Completed, Self::NotPlanned, Self::Duplicate];
665
666 pub const fn label(self) -> &'static str {
667 match self {
668 Self::Completed => "Completed",
669 Self::NotPlanned => "Not planned",
670 Self::Duplicate => "Duplicate",
671 }
672 }
673
674 pub const fn to_octocrab(self) -> octocrab::models::issues::IssueStateReason {
675 match self {
676 Self::Completed => octocrab::models::issues::IssueStateReason::Completed,
677 Self::NotPlanned => octocrab::models::issues::IssueStateReason::NotPlanned,
678 Self::Duplicate => octocrab::models::issues::IssueStateReason::Duplicate,
679 }
680 }
681}
682
683fn finish_teardown() -> Result<()> {
684 let mut stdout = stdout();
685 execute!(stdout, PopKeyboardEnhancementFlags)?;
686 execute!(stdout, DisableBracketedPaste)?;
687
688 Ok(())
689}
690
691fn setup_terminal() -> Result<()> {
692 let mut stdout = stdout();
693 execute!(
694 stdout,
695 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
696 )?;
697 execute!(
698 stdout,
699 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES)
700 )?;
701 execute!(
702 stdout,
703 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
704 )?;
705 execute!(
706 stdout,
707 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
708 )?;
709 execute!(stdout, EnableBracketedPaste)?;
710
711 Ok(())
712}
713
714fn setup_more_panic_hooks() {
715 let hook = std::panic::take_hook();
716 std::panic::set_hook(Box::new(move |info| {
717 tracing::error!(panic_info = ?info, "Panic occurred");
719 let _ = finish_teardown();
720 hook(info);
721 }));
722}