synd_term/application/
mod.rs

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    /// Construct `ApplicationBuilder`
106    pub fn builder() -> ApplicationBuilder {
107        ApplicationBuilder::default()
108    }
109
110    /// Construct `Application` from builder.
111    /// Configure keymaps for terminal use
112    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            // The secondary rate limit of the GitHub API is 100 concurrent requests, so we have set it to 90.
157            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    /// Initialize application.
200    /// Setup terminal and handle cache.
201    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            // Restore previous filter options
216            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    /// Restore terminal state and print something to console if necesseary
285    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        // Make sure inform after terminal restored
299        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); // for testing
356                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                            // paginate
434                            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                            // how we show fetched errors in ui?
441                            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                            // paginate
459                            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                            // do nothing
541                        }
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                    // Unlike the web UI, simply unsubscribing does not mark it as done
755                    // and it remains as unread.
756                    // Therefore, when reloading, the unsubscribed notification is displayed again.
757                    // To address this, we will implicitly mark it as done when unsubscribing.
758                    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                // TODO: Handle error case
920                return;
921            }
922        };
923        tracing::debug!("Got user modified feed subscription: {input}");
924        // the terminal state becomes strange after editing in the editor
925        self.terminal.force_redraw();
926
927        let fut = match InputParser::new(input.as_str()).parse_feed_subscription(&self.categories) {
928            Ok(input) => {
929                // Check for the duplicate subscription
930                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                // TODO: handle error case
965                tracing::warn!("{err}");
966                return;
967            }
968        };
969        // the terminal state becomes strange after editing in the editor
970        self.terminal.force_redraw();
971
972        let fut = match InputParser::new(input.as_str()).parse_feed_subscription(&self.categories) {
973            // Strictly, if the URL of the feed changed before and after an update
974            // it is not considered an edit, so it could be considered an error
975            // but currently we are allowing it
976            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        // try to open input screen in the browser
1322        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        // update informer use reqwest::blocking
1420        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        // https://github.com/tokio-rs/tokio/blob/e53b92a9939565edb33575fff296804279e5e419/tokio/src/time/instant.rs#L62
1464        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            // In the current test implementation, we synchronie
1491            // the assertion timing by waiting until jobs are empty.
1492            // However the future of refreshing the id token sleeps until it expires and remains in the jobs for long time
1493            // Therefore, we ignore scheduled jobs
1494            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}