1use std::{
2 collections::VecDeque,
3 future,
4 num::NonZero,
5 ops::{ControlFlow, Sub},
6 pin::Pin,
7 sync::Arc,
8 time::Duration,
9};
10
11use chrono::{DateTime, Utc};
12use crossterm::event::{Event as CrosstermEvent, KeyEvent, KeyEventKind};
13use either::Either;
14use futures_util::{FutureExt, Stream, StreamExt};
15use itertools::Itertools;
16use ratatui::widgets::Widget;
17use synd_auth::device_flow::DeviceAuthorizationResponse;
18use synd_feed::types::FeedUrl;
19use tokio::time::{Instant, Sleep};
20use update_informer::Version;
21use url::Url;
22
23use crate::{
24 application::event::KeyEventResult,
25 auth::{self, AuthenticationProvider, Credential, CredentialError, Verified},
26 client::{
27 github::{FetchNotificationsParams, GithubClient},
28 synd_api::{mutation::subscribe_feed::SubscribeFeedInput, Client, SyndApiError},
29 },
30 command::{ApiResponse, Command},
31 config::{self, Categories},
32 interact::Interact,
33 job::Jobs,
34 keymap::{KeymapId, Keymaps},
35 terminal::Terminal,
36 types::github::{IssueOrPullRequest, Notification},
37 ui::{
38 self,
39 components::{
40 authentication::AuthenticateState, filter::Filterer, gh_notifications::GhNotifications,
41 root::Root, subscription::UnsubscribeSelection, tabs::Tab, Components,
42 },
43 theme::{Palette, Theme},
44 },
45};
46
47mod direction;
48pub(crate) use direction::{Direction, IndexOutOfRange};
49
50mod in_flight;
51pub(crate) use in_flight::{InFlight, RequestId, RequestSequence};
52
53mod input_parser;
54use input_parser::InputParser;
55
56pub use auth::authenticator::{Authenticator, DeviceFlows, JwtService};
57
58mod clock;
59pub use clock::{Clock, SystemClock};
60
61mod cache;
62pub use cache::{Cache, LoadCacheError, PersistCacheError};
63
64mod builder;
65pub use builder::ApplicationBuilder;
66
67mod app_config;
68pub use app_config::{Config, Features};
69
70pub(crate) mod event;
71
72mod state;
73pub(crate) use state::TerminalFocus;
74use state::{Should, State};
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum Populate {
78 Append,
79 Replace,
80}
81
82pub struct Application {
83 clock: Box<dyn Clock>,
84 terminal: Terminal,
85 client: Client,
86 github_client: Option<GithubClient>,
87 jobs: Jobs,
88 background_jobs: Jobs,
89 components: Components,
90 interactor: Box<dyn Interact>,
91 authenticator: Authenticator,
92 in_flight: InFlight,
93 cache: Cache,
94 theme: Theme,
95 idle_timer: Pin<Box<Sleep>>,
96 config: Config,
97 key_handlers: event::KeyHandlers,
98 categories: Categories,
99 latest_release: Option<Version>,
100
101 state: State,
102}
103
104impl Application {
105 pub fn builder() -> ApplicationBuilder {
107 ApplicationBuilder::default()
108 }
109
110 fn new(
113 builder: ApplicationBuilder<
114 Terminal,
115 Client,
116 Categories,
117 Cache,
118 Config,
119 Theme,
120 Box<dyn Interact>,
121 >,
122 ) -> Self {
123 let ApplicationBuilder {
124 terminal,
125 client,
126 github_client,
127 categories,
128 cache,
129 config,
130 theme,
131 authenticator,
132 interactor,
133 clock,
134 dry_run,
135 } = builder;
136
137 let key_handlers = {
138 let mut keymaps = Keymaps::default();
139 keymaps.enable(KeymapId::Global);
140 keymaps.enable(KeymapId::Login);
141
142 let mut key_handlers = event::KeyHandlers::new();
143 key_handlers.push(event::KeyHandler::Keymaps(keymaps));
144 key_handlers
145 };
146 let mut state = State::new();
147 if dry_run {
148 state.flags = Should::Quit;
149 }
150
151 Self {
152 clock: clock.unwrap_or_else(|| Box::new(SystemClock)),
153 terminal,
154 client,
155 github_client,
156 jobs: Jobs::new(NonZero::new(90).unwrap()),
158 background_jobs: Jobs::new(NonZero::new(10).unwrap()),
159 components: Components::new(&config.features),
160 interactor,
161 authenticator: authenticator.unwrap_or_else(Authenticator::new),
162 in_flight: InFlight::new().with_throbber_timer_interval(config.throbber_timer_interval),
163 cache,
164 theme,
165 idle_timer: Box::pin(tokio::time::sleep(config.idle_timer_interval)),
166 config,
167 key_handlers,
168 categories,
169 latest_release: None,
170 state,
171 }
172 }
173
174 fn now(&self) -> DateTime<Utc> {
175 self.clock.now()
176 }
177
178 fn jwt_service(&self) -> &JwtService {
179 &self.authenticator.jwt_service
180 }
181
182 fn keymaps(&mut self) -> &mut Keymaps {
183 self.key_handlers.keymaps_mut().unwrap()
184 }
185
186 pub async fn run<S>(mut self, input: &mut S) -> anyhow::Result<()>
187 where
188 S: Stream<Item = std::io::Result<CrosstermEvent>> + Unpin,
189 {
190 self.init().await?;
191
192 self.event_loop(input).await;
193
194 self.cleanup().ok();
195
196 Ok(())
197 }
198
199 async fn init(&mut self) -> anyhow::Result<()> {
202 match self.terminal.init() {
203 Ok(()) => Ok(()),
204 Err(err) => {
205 if self.state.flags.contains(Should::Quit) {
206 tracing::warn!("Failed to init terminal: {err}");
207 Ok(())
208 } else {
209 Err(err)
210 }
211 }
212 }?;
213
214 if self.config.features.enable_github_notification {
215 match self.cache.load_gh_notification_filter_options() {
217 Ok(options) => {
218 self.components.gh_notifications =
219 GhNotifications::with_filter_options(options);
220 }
221 Err(err) => {
222 tracing::warn!("Load github notification filter options: {err}");
223 }
224 }
225 }
226
227 match self.restore_credential().await {
228 Ok(cred) => self.handle_initial_credential(cred),
229 Err(err) => tracing::warn!("Restore credential: {err}"),
230 }
231
232 Ok(())
233 }
234
235 async fn restore_credential(&self) -> Result<Verified<Credential>, CredentialError> {
236 let restore = auth::Restore {
237 jwt_service: self.jwt_service(),
238 cache: &self.cache,
239 now: self.now(),
240 persist_when_refreshed: true,
241 };
242 restore.restore().await
243 }
244
245 fn handle_initial_credential(&mut self, cred: Verified<Credential>) {
246 self.set_credential(cred);
247 self.initial_fetch();
248 self.check_latest_release();
249 self.components.auth.authenticated();
250 self.reset_idle_timer();
251 self.should_render();
252 self.keymaps()
253 .disable(KeymapId::Login)
254 .enable(KeymapId::Tabs)
255 .enable(KeymapId::Entries)
256 .enable(KeymapId::Filter);
257 self.config
258 .features
259 .enable_github_notification
260 .then(|| self.keymaps().enable(KeymapId::GhNotification));
261 }
262
263 fn set_credential(&mut self, cred: Verified<Credential>) {
264 self.schedule_credential_refreshing(&cred);
265 self.client.set_credential(cred);
266 }
267
268 fn initial_fetch(&mut self) {
269 tracing::info!("Initial fetch");
270 self.jobs.push(
271 future::ready(Ok(Command::FetchEntries {
272 after: None,
273 first: self.config.entries_per_pagination,
274 }))
275 .boxed(),
276 );
277 if self.config.features.enable_github_notification {
278 if let Some(fetch) = self.components.gh_notifications.fetch_next_if_needed() {
279 self.jobs.push(future::ready(Ok(fetch)).boxed());
280 }
281 }
282 }
283
284 fn cleanup(&mut self) -> anyhow::Result<()> {
286 if self.config.features.enable_github_notification {
287 let options = self.components.gh_notifications.filter_options();
288 match self.cache.persist_gh_notification_filter_options(options) {
289 Ok(()) => {}
290 Err(err) => {
291 tracing::warn!("Failed to persist github notification filter options: {err}");
292 }
293 }
294 }
295
296 self.terminal.restore()?;
297
298 self.inform_latest_release();
300 Ok(())
301 }
302
303 async fn event_loop<S>(&mut self, input: &mut S)
304 where
305 S: Stream<Item = std::io::Result<CrosstermEvent>> + Unpin,
306 {
307 self.render();
308
309 loop {
310 if self.event_loop_until_idle(input).await.is_break() {
311 break;
312 }
313 }
314 }
315
316 pub async fn event_loop_until_idle<S>(&mut self, input: &mut S) -> ControlFlow<()>
317 where
318 S: Stream<Item = std::io::Result<CrosstermEvent>> + Unpin,
319 {
320 let mut queue = VecDeque::with_capacity(2);
321
322 loop {
323 let command = tokio::select! {
324 biased;
325
326 Some(event) = input.next() => {
327 self.handle_terminal_event(event)
328 }
329 Some(command) = self.jobs.next() => {
330 Some(command.unwrap())
331 }
332 Some(command) = self.background_jobs.next() => {
333 Some(command.unwrap())
334 }
335 () = self.in_flight.throbber_timer() => {
336 Some(Command::RenderThrobber)
337 }
338 () = &mut self.idle_timer => {
339 Some(Command::Idle)
340 }
341 };
342
343 if let Some(command) = command {
344 queue.push_back(command);
345 self.apply(&mut queue);
346 }
347
348 if self.state.flags.contains(Should::Render) {
349 self.render();
350 self.state.flags.remove(Should::Render);
351 self.components.prompt.clear_error_message();
352 }
353
354 if self.state.flags.contains(Should::Quit) {
355 self.state.flags.remove(Should::Quit); break ControlFlow::Break(());
357 }
358 }
359 }
360
361 #[tracing::instrument(skip_all)]
362 fn apply(&mut self, queue: &mut VecDeque<Command>) {
363 while let Some(command) = queue.pop_front() {
364 let _guard = tracing::info_span!("apply", %command).entered();
365
366 match command {
367 Command::Nop => {}
368 Command::Quit => self.state.flags.insert(Should::Quit),
369 Command::ResizeTerminal { .. } => {
370 self.should_render();
371 }
372 Command::RenderThrobber => {
373 self.in_flight.reset_throbber_timer();
374 self.in_flight.inc_throbber_step();
375 self.should_render();
376 }
377 Command::Idle => {
378 self.handle_idle();
379 }
380 Command::Authenticate => {
381 if self.components.auth.state() != &AuthenticateState::NotAuthenticated {
382 continue;
383 }
384 let provider = self.components.auth.selected_provider();
385 self.init_device_flow(provider);
386 }
387 Command::MoveAuthenticationProvider(direction) => {
388 self.components.auth.move_selection(direction);
389 self.should_render();
390 }
391 Command::HandleApiResponse {
392 request_seq,
393 response,
394 } => {
395 self.in_flight.remove(request_seq);
396
397 match response {
398 ApiResponse::DeviceFlowAuthorization {
399 provider,
400 device_authorization,
401 } => {
402 self.handle_device_flow_authorization_response(
403 provider,
404 device_authorization,
405 );
406 }
407 ApiResponse::DeviceFlowCredential { credential } => {
408 self.complete_device_authroize_flow(credential);
409 }
410 ApiResponse::SubscribeFeed { feed } => {
411 self.components.subscription.upsert_subscribed_feed(*feed);
412 self.fetch_entries(
413 Populate::Replace,
414 None,
415 self.config.entries_per_pagination,
416 );
417 self.should_render();
418 }
419 ApiResponse::UnsubscribeFeed { url } => {
420 self.components.subscription.remove_unsubscribed_feed(&url);
421 self.components.entries.remove_unsubscribed_entries(&url);
422 self.components.filter.update_categories(
423 &self.categories,
424 Populate::Replace,
425 self.components.entries.entries(),
426 );
427 self.should_render();
428 }
429 ApiResponse::FetchSubscription {
430 populate,
431 subscription,
432 } => {
433 subscription.feeds.page_info.has_next_page.then(|| {
435 queue.push_back(Command::FetchSubscription {
436 after: subscription.feeds.page_info.end_cursor.clone(),
437 first: subscription.feeds.nodes.len().try_into().unwrap_or(0),
438 });
439 });
440 if !subscription.feeds.errors.is_empty() {
442 tracing::warn!(
443 "Failed fetched feeds: {:?}",
444 subscription.feeds.errors
445 );
446 }
447 self.components
448 .subscription
449 .update_subscription(populate, subscription);
450 self.should_render();
451 }
452 ApiResponse::FetchEntries { populate, payload } => {
453 self.components.filter.update_categories(
454 &self.categories,
455 populate,
456 payload.entries.as_slice(),
457 );
458 payload.page_info.has_next_page.then(|| {
460 queue.push_back(Command::FetchEntries {
461 after: payload.page_info.end_cursor.clone(),
462 first: self
463 .config
464 .entries_limit
465 .saturating_sub(
466 self.components.entries.count() + payload.entries.len(),
467 )
468 .min(payload.entries.len())
469 .try_into()
470 .unwrap_or(0),
471 });
472 });
473 self.components.entries.update_entries(populate, payload);
474 self.should_render();
475 }
476 ApiResponse::FetchGithubNotifications {
477 notifications,
478 populate,
479 } => {
480 self.components
481 .gh_notifications
482 .update_notifications(populate, notifications)
483 .into_iter()
484 .for_each(|command| queue.push_back(command));
485 self.components
486 .gh_notifications
487 .fetch_next_if_needed()
488 .into_iter()
489 .for_each(|command| queue.push_back(command));
490 if populate == Populate::Replace {
491 self.components.filter.clear_gh_notifications_categories();
492 }
493 self.should_render();
494 }
495 ApiResponse::FetchGithubIssue {
496 notification_id,
497 issue,
498 } => {
499 if let Some(notification) = self
500 .components
501 .gh_notifications
502 .update_issue(notification_id, issue, &self.categories)
503 {
504 let categories = notification.categories().cloned();
505 self.components.filter.update_gh_notification_categories(
506 &self.categories,
507 Populate::Append,
508 categories,
509 );
510 }
511 self.should_render();
512 }
513 ApiResponse::FetchGithubPullRequest {
514 notification_id,
515 pull_request,
516 } => {
517 if let Some(notification) =
518 self.components.gh_notifications.update_pull_request(
519 notification_id,
520 pull_request,
521 &self.categories,
522 )
523 {
524 let categories = notification.categories().cloned();
525 self.components.filter.update_gh_notification_categories(
526 &self.categories,
527 Populate::Append,
528 categories,
529 );
530 }
531 self.should_render();
532 }
533 ApiResponse::MarkGithubNotificationAsDone { notification_id } => {
534 self.components
535 .gh_notifications
536 .marked_as_done(notification_id);
537 self.should_render();
538 }
539 ApiResponse::UnsubscribeGithubThread { .. } => {
540 }
542 }
543 }
544 Command::RefreshCredential { credential } => {
545 self.set_credential(credential);
546 }
547 Command::MoveTabSelection(direction) => {
548 self.keymaps()
549 .disable(KeymapId::Subscription)
550 .disable(KeymapId::Entries)
551 .disable(KeymapId::GhNotification);
552
553 match self.components.tabs.move_selection(direction) {
554 Tab::Feeds => {
555 self.keymaps().enable(KeymapId::Subscription);
556 if !self.components.subscription.has_subscription() {
557 queue.push_back(Command::FetchSubscription {
558 after: None,
559 first: self.config.feeds_per_pagination,
560 });
561 }
562 }
563 Tab::Entries => {
564 self.keymaps().enable(KeymapId::Entries);
565 }
566 Tab::GitHub => {
567 self.keymaps().enable(KeymapId::GhNotification);
568 }
569 }
570 self.should_render();
571 }
572 Command::MoveSubscribedFeed(direction) => {
573 self.components.subscription.move_selection(direction);
574 self.should_render();
575 }
576 Command::MoveSubscribedFeedFirst => {
577 self.components.subscription.move_first();
578 self.should_render();
579 }
580 Command::MoveSubscribedFeedLast => {
581 self.components.subscription.move_last();
582 self.should_render();
583 }
584 Command::PromptFeedSubscription => {
585 self.prompt_feed_subscription();
586 self.should_render();
587 }
588 Command::PromptFeedEdition => {
589 self.prompt_feed_edition();
590 self.should_render();
591 }
592 Command::PromptFeedUnsubscription => {
593 if self.components.subscription.selected_feed().is_some() {
594 self.components.subscription.toggle_unsubscribe_popup(true);
595 self.keymaps().enable(KeymapId::UnsubscribePopupSelection);
596 self.should_render();
597 }
598 }
599 Command::MoveFeedUnsubscriptionPopupSelection(direction) => {
600 self.components
601 .subscription
602 .move_unsubscribe_popup_selection(direction);
603 self.should_render();
604 }
605 Command::SelectFeedUnsubscriptionPopup => {
606 if let (UnsubscribeSelection::Yes, Some(feed)) =
607 self.components.subscription.unsubscribe_popup_selection()
608 {
609 self.unsubscribe_feed(feed.url.clone());
610 }
611 queue.push_back(Command::CancelFeedUnsubscriptionPopup);
612 self.should_render();
613 }
614 Command::CancelFeedUnsubscriptionPopup => {
615 self.components.subscription.toggle_unsubscribe_popup(false);
616 self.keymaps().disable(KeymapId::UnsubscribePopupSelection);
617 self.should_render();
618 }
619 Command::SubscribeFeed { input } => {
620 self.subscribe_feed(input);
621 self.should_render();
622 }
623 Command::FetchSubscription { after, first } => {
624 self.fetch_subscription(Populate::Append, after, first);
625 }
626 Command::ReloadSubscription => {
627 self.fetch_subscription(
628 Populate::Replace,
629 None,
630 self.config.feeds_per_pagination,
631 );
632 self.should_render();
633 }
634 Command::OpenFeed => {
635 self.open_feed();
636 }
637 Command::FetchEntries { after, first } => {
638 self.fetch_entries(Populate::Append, after, first);
639 }
640 Command::ReloadEntries => {
641 self.fetch_entries(Populate::Replace, None, self.config.entries_per_pagination);
642 self.should_render();
643 }
644 Command::MoveEntry(direction) => {
645 self.components.entries.move_selection(direction);
646 self.should_render();
647 }
648 Command::MoveEntryFirst => {
649 self.components.entries.move_first();
650 self.should_render();
651 }
652 Command::MoveEntryLast => {
653 self.components.entries.move_last();
654 self.should_render();
655 }
656 Command::OpenEntry => {
657 self.open_entry();
658 }
659 Command::BrowseEntry => {
660 self.browse_entry();
661 }
662 Command::MoveFilterRequirement(direction) => {
663 let filterer = self.components.filter.move_requirement(direction);
664 self.apply_filterer(filterer)
665 .into_iter()
666 .for_each(|command| queue.push_back(command));
667 self.should_render();
668 }
669 Command::ActivateCategoryFilterling => {
670 let keymap = self
671 .components
672 .filter
673 .activate_category_filtering(self.components.tabs.current().into());
674 self.keymaps().update(KeymapId::CategoryFiltering, keymap);
675 self.should_render();
676 }
677 Command::ActivateSearchFiltering => {
678 let prompt = self.components.filter.activate_search_filtering();
679 self.key_handlers.push(event::KeyHandler::Prompt(prompt));
680 self.should_render();
681 }
682 Command::PromptChanged => {
683 if self.components.filter.is_search_active() {
684 let filterer = self
685 .components
686 .filter
687 .filterer(self.components.tabs.current().into());
688 self.apply_filterer(filterer)
689 .into_iter()
690 .for_each(|command| queue.push_back(command));
691 self.should_render();
692 }
693 }
694 Command::DeactivateFiltering => {
695 self.components.filter.deactivate_filtering();
696 self.keymaps().disable(KeymapId::CategoryFiltering);
697 self.key_handlers.remove_prompt();
698 self.should_render();
699 }
700 Command::ToggleFilterCategory { category, lane } => {
701 let filter = self
702 .components
703 .filter
704 .toggle_category_state(&category, lane);
705 self.apply_filterer(filter)
706 .into_iter()
707 .for_each(|command| queue.push_back(command));
708 self.should_render();
709 }
710 Command::ActivateAllFilterCategories { lane } => {
711 let filterer = self.components.filter.activate_all_categories_state(lane);
712 self.apply_filterer(filterer)
713 .into_iter()
714 .for_each(|command| queue.push_back(command));
715 self.should_render();
716 }
717 Command::DeactivateAllFilterCategories { lane } => {
718 let filterer = self.components.filter.deactivate_all_categories_state(lane);
719 self.apply_filterer(filterer)
720 .into_iter()
721 .for_each(|command| queue.push_back(command));
722 self.should_render();
723 }
724 Command::FetchGhNotifications { populate, params } => {
725 self.fetch_gh_notifications(populate, params);
726 }
727 Command::MoveGhNotification(direction) => {
728 self.components.gh_notifications.move_selection(direction);
729 self.should_render();
730 }
731 Command::MoveGhNotificationFirst => {
732 self.components.gh_notifications.move_first();
733 self.should_render();
734 }
735 Command::MoveGhNotificationLast => {
736 self.components.gh_notifications.move_last();
737 self.should_render();
738 }
739 Command::OpenGhNotification { with_mark_as_done } => {
740 self.open_notification();
741 with_mark_as_done.then(|| self.mark_gh_notification_as_done(false));
742 }
743 Command::ReloadGhNotifications => {
744 let params = self.components.gh_notifications.reload();
745 self.fetch_gh_notifications(Populate::Replace, params);
746 }
747 Command::FetchGhNotificationDetails { contexts } => {
748 self.fetch_gh_notification_details(contexts);
749 }
750 Command::MarkGhNotificationAsDone { all } => {
751 self.mark_gh_notification_as_done(all);
752 }
753 Command::UnsubscribeGhThread => {
754 self.unsubscribe_gh_thread();
759 self.mark_gh_notification_as_done(false);
760 }
761 Command::OpenGhNotificationFilterPopup => {
762 self.components.gh_notifications.open_filter_popup();
763 self.keymaps().enable(KeymapId::GhNotificationFilterPopup);
764 self.keymaps().disable(KeymapId::GhNotification);
765 self.keymaps().disable(KeymapId::Filter);
766 self.keymaps().disable(KeymapId::Entries);
767 self.keymaps().disable(KeymapId::Subscription);
768 self.should_render();
769 }
770 Command::CloseGhNotificationFilterPopup => {
771 self.components
772 .gh_notifications
773 .close_filter_popup()
774 .into_iter()
775 .for_each(|command| queue.push_back(command));
776 self.keymaps().disable(KeymapId::GhNotificationFilterPopup);
777 self.keymaps().enable(KeymapId::GhNotification);
778 self.keymaps().enable(KeymapId::Filter);
779 self.keymaps().enable(KeymapId::Entries);
780 self.keymaps().enable(KeymapId::Subscription);
781 self.should_render();
782 }
783 Command::UpdateGhnotificationFilterPopupOptions(updater) => {
784 self.components
785 .gh_notifications
786 .update_filter_options(&updater);
787 self.should_render();
788 }
789 Command::RotateTheme => {
790 self.rotate_theme();
791 self.should_render();
792 }
793 Command::InformLatestRelease(version) => {
794 self.latest_release = Some(version);
795 }
796 Command::HandleError { message } => {
797 self.handle_error_message(message, None);
798 }
799 Command::HandleApiError { error, request_seq } => {
800 let message = match Arc::into_inner(error).expect("error never cloned") {
801 SyndApiError::Unauthorized { url } => {
802 tracing::warn!(
803 "api return unauthorized status code. the cached credential are likely invalid, so try to clean cache"
804 );
805 self.cache.clean().ok();
806
807 format!(
808 "{} unauthorized. please login again",
809 url.map(|url| url.to_string()).unwrap_or_default(),
810 )
811 }
812 SyndApiError::BuildRequest(err) => {
813 format!("build request failed: {err} this is a BUG")
814 }
815
816 SyndApiError::Graphql { errors } => {
817 errors.into_iter().map(|err| err.to_string()).join(", ")
818 }
819 SyndApiError::SubscribeFeed(err) => err.to_string(),
820
821 SyndApiError::Internal(err) => format!("internal error: {err}"),
822 };
823 self.handle_error_message(message, Some(request_seq));
824 }
825 Command::HandleOauthApiError { error, request_seq } => {
826 self.handle_error_message(error.to_string(), Some(request_seq));
827 }
828 Command::HandleGithubApiError { error, request_seq } => {
829 self.handle_error_message(error.to_string(), Some(request_seq));
830 }
831 }
832 }
833 }
834
835 fn handle_error_message(
836 &mut self,
837 error_message: String,
838 request_seq: Option<RequestSequence>,
839 ) {
840 tracing::error!("{error_message}");
841
842 if let Some(request_seq) = request_seq {
843 self.in_flight.remove(request_seq);
844 }
845
846 self.components.prompt.set_error_message(error_message);
847 self.should_render();
848 }
849
850 #[inline]
851 fn should_render(&mut self) {
852 self.state.flags.insert(Should::Render);
853 }
854
855 fn render(&mut self) {
856 let cx = ui::Context {
857 theme: &self.theme,
858 in_flight: &self.in_flight,
859 categories: &self.categories,
860 focus: self.state.focus(),
861 now: self.now(),
862 tab: self.components.tabs.current(),
863 };
864 let root = Root::new(&self.components, cx);
865
866 self.terminal
867 .render(|frame| Widget::render(root, frame.area(), frame.buffer_mut()))
868 .expect("Failed to render");
869 }
870
871 fn handle_terminal_event(&mut self, event: std::io::Result<CrosstermEvent>) -> Option<Command> {
872 match event.unwrap() {
873 CrosstermEvent::Resize(columns, rows) => Some(Command::ResizeTerminal {
874 _columns: columns,
875 _rows: rows,
876 }),
877 CrosstermEvent::FocusGained => {
878 self.should_render();
879 self.state.focus_gained()
880 }
881 CrosstermEvent::FocusLost => {
882 self.should_render();
883 self.state.focus_lost()
884 }
885 CrosstermEvent::Key(KeyEvent {
886 kind: KeyEventKind::Release,
887 ..
888 }) => None,
889 CrosstermEvent::Key(key) => {
890 tracing::debug!("Handle key event: {key:?}");
891
892 self.reset_idle_timer();
893
894 match self.key_handlers.handle(key) {
895 KeyEventResult::Consumed {
896 command,
897 should_render,
898 } => {
899 should_render.then(|| self.should_render());
900 command
901 }
902 KeyEventResult::Ignored => None,
903 }
904 }
905 _ => None,
906 }
907 }
908}
909
910impl Application {
911 fn prompt_feed_subscription(&mut self) {
912 let input = match self
913 .interactor
914 .open_editor(InputParser::SUSBSCRIBE_FEED_PROMPT)
915 {
916 Ok(input) => input,
917 Err(err) => {
918 tracing::warn!("{err}");
919 return;
921 }
922 };
923 tracing::debug!("Got user modified feed subscription: {input}");
924 self.terminal.force_redraw();
926
927 let fut = match InputParser::new(input.as_str()).parse_feed_subscription(&self.categories) {
928 Ok(input) => {
929 if self
931 .components
932 .subscription
933 .is_already_subscribed(&input.url)
934 {
935 let message = format!("{} already subscribed", input.url);
936 future::ready(Ok(Command::HandleError { message })).boxed()
937 } else {
938 future::ready(Ok(Command::SubscribeFeed { input })).boxed()
939 }
940 }
941
942 Err(err) => async move {
943 Ok(Command::HandleError {
944 message: err.to_string(),
945 })
946 }
947 .boxed(),
948 };
949
950 self.jobs.push(fut);
951 }
952
953 fn prompt_feed_edition(&mut self) {
954 let Some(feed) = self.components.subscription.selected_feed() else {
955 return;
956 };
957
958 let input = match self
959 .interactor
960 .open_editor(InputParser::edit_feed_prompt(feed).as_str())
961 {
962 Ok(input) => input,
963 Err(err) => {
964 tracing::warn!("{err}");
966 return;
967 }
968 };
969 self.terminal.force_redraw();
971
972 let fut = match InputParser::new(input.as_str()).parse_feed_subscription(&self.categories) {
973 Ok(input) => async move { Ok(Command::SubscribeFeed { input }) }.boxed(),
977 Err(err) => async move {
978 Ok(Command::HandleError {
979 message: err.to_string(),
980 })
981 }
982 .boxed(),
983 };
984
985 self.jobs.push(fut);
986 }
987
988 fn subscribe_feed(&mut self, input: SubscribeFeedInput) {
989 let client = self.client.clone();
990 let request_seq = self.in_flight.add(RequestId::SubscribeFeed);
991 let fut = async move {
992 match client.subscribe_feed(input).await {
993 Ok(feed) => Ok(Command::HandleApiResponse {
994 request_seq,
995 response: ApiResponse::SubscribeFeed {
996 feed: Box::new(feed),
997 },
998 }),
999 Err(error) => Ok(Command::api_error(error, request_seq)),
1000 }
1001 }
1002 .boxed();
1003 self.jobs.push(fut);
1004 }
1005
1006 fn unsubscribe_feed(&mut self, url: FeedUrl) {
1007 let client = self.client.clone();
1008 let request_seq = self.in_flight.add(RequestId::UnsubscribeFeed);
1009 let fut = async move {
1010 match client.unsubscribe_feed(url.clone()).await {
1011 Ok(()) => Ok(Command::HandleApiResponse {
1012 request_seq,
1013 response: ApiResponse::UnsubscribeFeed { url },
1014 }),
1015 Err(err) => Ok(Command::api_error(err, request_seq)),
1016 }
1017 }
1018 .boxed();
1019 self.jobs.push(fut);
1020 }
1021
1022 fn mark_gh_notification_as_done(&mut self, all: bool) {
1023 let ids = if all {
1024 Either::Left(
1025 self.components
1026 .gh_notifications
1027 .marking_as_done_all()
1028 .into_iter(),
1029 )
1030 } else {
1031 let Some(id) = self.components.gh_notifications.marking_as_done() else {
1032 return;
1033 };
1034 Either::Right(std::iter::once(id))
1035 };
1036
1037 for id in ids {
1038 let request_seq = self
1039 .in_flight
1040 .add(RequestId::MarkGithubNotificationAsDone { id });
1041 let Some(client) = self.github_client.clone() else {
1042 return;
1043 };
1044 let fut = async move {
1045 match client.mark_thread_as_done(id).await {
1046 Ok(()) => Ok(Command::HandleApiResponse {
1047 request_seq,
1048 response: ApiResponse::MarkGithubNotificationAsDone {
1049 notification_id: id,
1050 },
1051 }),
1052 Err(error) => Ok(Command::HandleGithubApiError {
1053 error: Arc::new(error),
1054 request_seq,
1055 }),
1056 }
1057 }
1058 .boxed();
1059 self.jobs.push(fut);
1060 }
1061 }
1062
1063 fn unsubscribe_gh_thread(&mut self) {
1064 let Some(id) = self
1065 .components
1066 .gh_notifications
1067 .selected_notification()
1068 .and_then(|n| n.thread_id)
1069 else {
1070 return;
1071 };
1072 let client = self.github_client.as_ref().unwrap().clone();
1073 let request_seq = self.in_flight.add(RequestId::UnsubscribeGithubThread);
1074 let fut = async move {
1075 match client.unsubscribe_thread(id).await {
1076 Ok(()) => Ok(Command::HandleApiResponse {
1077 request_seq,
1078 response: ApiResponse::UnsubscribeGithubThread {},
1079 }),
1080 Err(error) => Ok(Command::HandleGithubApiError {
1081 error: Arc::new(error),
1082 request_seq,
1083 }),
1084 }
1085 }
1086 .boxed();
1087 self.jobs.push(fut);
1088 }
1089}
1090
1091impl Application {
1092 fn open_feed(&mut self) {
1093 let Some(feed_website_url) = self
1094 .components
1095 .subscription
1096 .selected_feed()
1097 .and_then(|feed| feed.website_url.clone())
1098 else {
1099 return;
1100 };
1101 match Url::parse(&feed_website_url) {
1102 Ok(url) => {
1103 self.interactor.open_browser(url).ok();
1104 }
1105 Err(err) => {
1106 tracing::warn!("Try to open invalid feed url: {feed_website_url} {err}");
1107 }
1108 };
1109 }
1110
1111 fn open_entry(&mut self) {
1112 if let Some(url) = self.selected_entry_url() {
1113 if let Err(err) = self.interactor.open_browser(url) {
1114 self.handle_error_message(format!("open browser: {err}"), None);
1115 }
1116 }
1117 }
1118
1119 fn browse_entry(&mut self) {
1120 if let Some(url) = self.selected_entry_url() {
1121 if let Err(err) = self.interactor.open_text_browser(url) {
1122 self.handle_error_message(format!("open browser: {err}"), None);
1123 }
1124 self.terminal.force_redraw();
1125 }
1126 }
1127
1128 fn selected_entry_url(&self) -> Option<Url> {
1129 let entry_website_url = self.components.entries.selected_entry_website_url()?;
1130 match Url::parse(entry_website_url) {
1131 Ok(url) => Some(url),
1132 Err(err) => {
1133 tracing::warn!("Try to open/browse invalid entry url: {entry_website_url} {err}");
1134 None
1135 }
1136 }
1137 }
1138
1139 fn open_notification(&mut self) {
1140 let Some(notification_url) = self
1141 .components
1142 .gh_notifications
1143 .selected_notification()
1144 .and_then(Notification::browser_url)
1145 else {
1146 return;
1147 };
1148 self.interactor.open_browser(notification_url).ok();
1149 }
1150}
1151
1152impl Application {
1153 #[tracing::instrument(skip(self))]
1154 fn fetch_subscription(&mut self, populate: Populate, after: Option<String>, first: i64) {
1155 if first <= 0 {
1156 return;
1157 }
1158 let client = self.client.clone();
1159 let request_seq = self.in_flight.add(RequestId::FetchSubscription);
1160 let fut = async move {
1161 match client.fetch_subscription(after, Some(first)).await {
1162 Ok(subscription) => Ok(Command::HandleApiResponse {
1163 request_seq,
1164 response: ApiResponse::FetchSubscription {
1165 populate,
1166 subscription,
1167 },
1168 }),
1169 Err(err) => Ok(Command::api_error(err, request_seq)),
1170 }
1171 }
1172 .boxed();
1173 self.jobs.push(fut);
1174 }
1175
1176 #[tracing::instrument(skip(self))]
1177 fn fetch_entries(&mut self, populate: Populate, after: Option<String>, first: i64) {
1178 if first <= 0 {
1179 return;
1180 }
1181 let client = self.client.clone();
1182 let request_seq = self.in_flight.add(RequestId::FetchEntries);
1183 let fut = async move {
1184 match client.fetch_entries(after, first).await {
1185 Ok(payload) => Ok(Command::HandleApiResponse {
1186 request_seq,
1187 response: ApiResponse::FetchEntries { populate, payload },
1188 }),
1189 Err(error) => Ok(Command::HandleApiError {
1190 error: Arc::new(error),
1191 request_seq,
1192 }),
1193 }
1194 }
1195 .boxed();
1196 self.jobs.push(fut);
1197 }
1198
1199 #[tracing::instrument(skip(self))]
1200 fn fetch_gh_notifications(&mut self, populate: Populate, params: FetchNotificationsParams) {
1201 let client = self
1202 .github_client
1203 .clone()
1204 .expect("Github client not found, this is a BUG");
1205 let request_seq = self
1206 .in_flight
1207 .add(RequestId::FetchGithubNotifications { page: params.page });
1208 let fut = async move {
1209 match client.fetch_notifications(params).await {
1210 Ok(notifications) => Ok(Command::HandleApiResponse {
1211 request_seq,
1212 response: ApiResponse::FetchGithubNotifications {
1213 populate,
1214 notifications,
1215 },
1216 }),
1217 Err(error) => Ok(Command::HandleGithubApiError {
1218 error: Arc::new(error),
1219 request_seq,
1220 }),
1221 }
1222 }
1223 .boxed();
1224 self.jobs.push(fut);
1225 }
1226
1227 #[tracing::instrument(skip_all)]
1228 fn fetch_gh_notification_details(&mut self, contexts: Vec<IssueOrPullRequest>) {
1229 let client = self
1230 .github_client
1231 .clone()
1232 .expect("Github client not found, this is a BUG");
1233
1234 for context in contexts {
1235 let client = client.clone();
1236
1237 let fut = match context {
1238 Either::Left(issue) => {
1239 let request_seq = self
1240 .in_flight
1241 .add(RequestId::FetchGithubIssue { id: issue.id });
1242 let notification_id = issue.notification_id;
1243 async move {
1244 match client.fetch_issue(issue).await {
1245 Ok(issue) => Ok(Command::HandleApiResponse {
1246 request_seq,
1247 response: ApiResponse::FetchGithubIssue {
1248 notification_id,
1249 issue,
1250 },
1251 }),
1252 Err(error) => Ok(Command::HandleGithubApiError {
1253 error: Arc::new(error),
1254 request_seq,
1255 }),
1256 }
1257 }
1258 .boxed()
1259 }
1260 Either::Right(pull_request) => {
1261 let request_seq = self.in_flight.add(RequestId::FetchGithubPullRequest {
1262 id: pull_request.id,
1263 });
1264 let notification_id = pull_request.notification_id;
1265
1266 async move {
1267 match client.fetch_pull_request(pull_request).await {
1268 Ok(pull_request) => Ok(Command::HandleApiResponse {
1269 request_seq,
1270 response: ApiResponse::FetchGithubPullRequest {
1271 notification_id,
1272 pull_request,
1273 },
1274 }),
1275 Err(error) => Ok(Command::HandleGithubApiError {
1276 error: Arc::new(error),
1277 request_seq,
1278 }),
1279 }
1280 }
1281 .boxed()
1282 }
1283 };
1284 self.jobs.push(fut);
1285 }
1286 }
1287}
1288
1289impl Application {
1290 #[tracing::instrument(skip(self))]
1291 fn init_device_flow(&mut self, provider: AuthenticationProvider) {
1292 tracing::info!("Start authenticate");
1293
1294 let authenticator = self.authenticator.clone();
1295 let request_seq = self.in_flight.add(RequestId::DeviceFlowDeviceAuthorize);
1296 let fut = async move {
1297 match authenticator.init_device_flow(provider).await {
1298 Ok(device_authorization) => Ok(Command::HandleApiResponse {
1299 request_seq,
1300 response: ApiResponse::DeviceFlowAuthorization {
1301 provider,
1302 device_authorization,
1303 },
1304 }),
1305 Err(err) => Ok(Command::oauth_api_error(err, request_seq)),
1306 }
1307 }
1308 .boxed();
1309 self.jobs.push(fut);
1310 }
1311
1312 fn handle_device_flow_authorization_response(
1313 &mut self,
1314 provider: AuthenticationProvider,
1315 device_authorization: DeviceAuthorizationResponse,
1316 ) {
1317 self.components
1318 .auth
1319 .set_device_authorization_response(device_authorization.clone());
1320 self.should_render();
1321 if let Ok(url) = Url::parse(device_authorization.verification_uri().to_string().as_str()) {
1323 self.interactor.open_browser(url).ok();
1324 }
1325
1326 let authenticator = self.authenticator.clone();
1327 let now = self.now();
1328 let request_seq = self.in_flight.add(RequestId::DeviceFlowPollAccessToken);
1329 let fut = async move {
1330 match authenticator
1331 .poll_device_flow_access_token(now, provider, device_authorization)
1332 .await
1333 {
1334 Ok(credential) => Ok(Command::HandleApiResponse {
1335 request_seq,
1336 response: ApiResponse::DeviceFlowCredential { credential },
1337 }),
1338 Err(err) => Ok(Command::oauth_api_error(err, request_seq)),
1339 }
1340 }
1341 .boxed();
1342
1343 self.jobs.push(fut);
1344 }
1345
1346 fn complete_device_authroize_flow(&mut self, cred: Verified<Credential>) {
1347 if let Err(err) = self.cache.persist_credential(&cred) {
1348 tracing::error!("Failed to save credential to cache: {err}");
1349 }
1350
1351 self.handle_initial_credential(cred);
1352 }
1353
1354 fn schedule_credential_refreshing(&mut self, cred: &Verified<Credential>) {
1355 match &**cred {
1356 Credential::Github { .. } => {}
1357 Credential::Google {
1358 refresh_token,
1359 expired_at,
1360 ..
1361 } => {
1362 let until_expire = expired_at
1363 .sub(config::credential::EXPIRE_MARGIN)
1364 .sub(self.now())
1365 .to_std()
1366 .unwrap_or(config::credential::FALLBACK_EXPIRE);
1367 let jwt_service = self.jwt_service().clone();
1368 let refresh_token = refresh_token.clone();
1369 let fut = async move {
1370 tokio::time::sleep(until_expire).await;
1371
1372 tracing::debug!("Refresh google credential");
1373 match jwt_service.refresh_google_id_token(&refresh_token).await {
1374 Ok(credential) => Ok(Command::RefreshCredential { credential }),
1375 Err(err) => Ok(Command::HandleError {
1376 message: err.to_string(),
1377 }),
1378 }
1379 }
1380 .boxed();
1381 self.background_jobs.push(fut);
1382 }
1383 }
1384 }
1385}
1386
1387impl Application {
1388 #[must_use]
1389 fn apply_filterer(&mut self, filterer: Filterer) -> Option<Command> {
1390 match filterer {
1391 Filterer::Feed(filterer) => {
1392 self.components.entries.update_filterer(filterer.clone());
1393 self.components.subscription.update_filterer(filterer);
1394 None
1395 }
1396 Filterer::GhNotification(filterer) => {
1397 self.components.gh_notifications.update_filterer(filterer);
1398 self.components.gh_notifications.fetch_next_if_needed()
1399 }
1400 }
1401 }
1402
1403 fn rotate_theme(&mut self) {
1404 let p = match self.theme.name {
1405 "ferra" => Palette::solarized_dark(),
1406 "solarized_dark" => Palette::helix(),
1407 "helix" => Palette::dracula(),
1408 "dracula" => Palette::eldritch(),
1409 _ => Palette::ferra(),
1410 };
1411 self.theme = Theme::with_palette(p);
1412 }
1413}
1414
1415impl Application {
1416 fn check_latest_release(&mut self) {
1417 use update_informer::{registry, Check};
1418
1419 let check = tokio::task::spawn_blocking(|| {
1421 let name = env!("CARGO_PKG_NAME");
1422 let version = env!("CARGO_PKG_VERSION");
1423 #[cfg(not(test))]
1424 let informer = update_informer::new(registry::Crates, name, version)
1425 .interval(Duration::from_secs(60 * 60 * 24))
1426 .timeout(Duration::from_secs(5));
1427
1428 #[cfg(test)]
1429 let informer = update_informer::fake(registry::Crates, name, version, "v1.0.0");
1430
1431 informer.check_version().ok().flatten()
1432 });
1433 let fut = async move {
1434 match check.await {
1435 Ok(Some(version)) => Ok(Command::InformLatestRelease(version)),
1436 _ => Ok(Command::Nop),
1437 }
1438 }
1439 .boxed();
1440 self.jobs.push(fut);
1441 }
1442
1443 fn inform_latest_release(&self) {
1444 let current_version = env!("CARGO_PKG_VERSION");
1445 if let Some(new_version) = &self.latest_release {
1446 println!("A new release of synd is available: v{current_version} -> {new_version}");
1447 }
1448 }
1449}
1450
1451impl Application {
1452 fn handle_idle(&mut self) {
1453 self.clear_idle_timer();
1454
1455 #[cfg(feature = "integration")]
1456 {
1457 tracing::debug!("Quit for idle");
1458 self.state.flags.insert(Should::Quit);
1459 }
1460 }
1461
1462 pub fn clear_idle_timer(&mut self) {
1463 self.idle_timer
1465 .as_mut()
1466 .reset(Instant::now() + Duration::from_secs(86400 * 365 * 30));
1467 }
1468
1469 pub fn reset_idle_timer(&mut self) {
1470 self.idle_timer
1471 .as_mut()
1472 .reset(Instant::now() + self.config.idle_timer_interval);
1473 }
1474}
1475
1476#[cfg(feature = "integration")]
1477impl Application {
1478 pub fn buffer(&self) -> &ratatui::buffer::Buffer {
1479 self.terminal.buffer()
1480 }
1481
1482 pub async fn wait_until_jobs_completed<S>(&mut self, input: &mut S)
1483 where
1484 S: Stream<Item = std::io::Result<CrosstermEvent>> + Unpin,
1485 {
1486 loop {
1487 self.event_loop_until_idle(input).await;
1488 self.reset_idle_timer();
1489
1490 if self.jobs.is_empty() {
1495 break;
1496 }
1497 }
1498 }
1499
1500 pub async fn reload_cache(&mut self) -> anyhow::Result<()> {
1501 match self.restore_credential().await {
1502 Ok(cred) => self.handle_initial_credential(cred),
1503 Err(err) => return Err(err.into()),
1504 }
1505 Ok(())
1506 }
1507}