diff --git a/Cargo.lock b/Cargo.lock index 1ac12610..89fed0f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,6 +1599,7 @@ dependencies = [ "tokio", "tokio-rustls 0.25.0", "tower-service", + "webpki-roots 0.26.1", ] [[package]] @@ -3737,6 +3738,7 @@ dependencies = [ "headers", "jsonwebtoken", "kvsd", + "octocrab", "serde", "serde_json", "synd-auth", diff --git a/Cargo.toml b/Cargo.toml index 10f01cdf..a8addbd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ itertools = { version = "0.13", default-features = false, features = [" jsonwebtoken = { version = "9.3.0" } kvsd = { version = "0.1.3", default-features = false } moka = { version = "0.12.7", features = ["future"] } +octocrab = { version = "0.38.0", features = ["rustls-webpki-tokio"] } parse_duration = { version = "2.1.1" } proptest = { version = "1.5.0" } rand = { version = "0.8.5" } diff --git a/clippy.toml b/clippy.toml index f5f182fb..b4350906 100644 --- a/clippy.toml +++ b/clippy.toml @@ -12,5 +12,6 @@ allowed-duplicate-crates = [ "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", - ] + +too-many-lines-threshold = 150 diff --git a/crates/synd_term/Cargo.toml b/crates/synd_term/Cargo.toml index 67d1d2b8..9473afef 100644 --- a/crates/synd_term/Cargo.toml +++ b/crates/synd_term/Cargo.toml @@ -37,7 +37,7 @@ html2text = { version = "0.12" } itertools = { workspace = true } nom = { version = "7.1.3", default-features = false, features = ["std"] } nucleo = "0.5.0" -octocrab = "0.38.0" +octocrab = { workspace = true } open = "5.1.4" parse_duration = { workspace = true } ratatui = { version = "0.26.3" } diff --git a/crates/synd_term/src/application/builder.rs b/crates/synd_term/src/application/builder.rs index 3368da6a..4f059dc2 100644 --- a/crates/synd_term/src/application/builder.rs +++ b/crates/synd_term/src/application/builder.rs @@ -1,5 +1,5 @@ use crate::{ - application::{Application, Authenticator, Cache, Config}, + application::{Application, Authenticator, Cache, Clock, Config}, client::{github::GithubClient, Client}, config::Categories, interact::Interactor, @@ -25,6 +25,7 @@ pub struct ApplicationBuilder< pub(super) authenticator: Option, pub(super) interactor: Option, pub(super) github_client: Option, + pub(super) clock: Option>, pub(super) dry_run: bool, } @@ -40,6 +41,7 @@ impl Default for ApplicationBuilder { authenticator: None, interactor: None, github_client: None, + clock: None, dry_run: false, } } @@ -58,6 +60,7 @@ impl ApplicationBuilder<(), T1, T2, T3, T4, T5> { authenticator: self.authenticator, interactor: self.interactor, github_client: self.github_client, + clock: self.clock, dry_run: self.dry_run, } } @@ -76,6 +79,7 @@ impl ApplicationBuilder { authenticator: self.authenticator, interactor: self.interactor, github_client: self.github_client, + clock: self.clock, dry_run: self.dry_run, } } @@ -97,6 +101,7 @@ impl ApplicationBuilder { authenticator: self.authenticator, interactor: self.interactor, github_client: self.github_client, + clock: self.clock, dry_run: self.dry_run, } } @@ -115,6 +120,7 @@ impl ApplicationBuilder { authenticator: self.authenticator, interactor: self.interactor, github_client: self.github_client, + clock: self.clock, dry_run: self.dry_run, } } @@ -133,6 +139,7 @@ impl ApplicationBuilder { authenticator: self.authenticator, interactor: self.interactor, github_client: self.github_client, + clock: self.clock, dry_run: self.dry_run, } } @@ -151,6 +158,7 @@ impl ApplicationBuilder { authenticator: self.authenticator, interactor: self.interactor, github_client: self.github_client, + clock: self.clock, dry_run: self.dry_run, } } @@ -181,6 +189,14 @@ impl ApplicationBuilder { } } + #[must_use] + pub fn clock(self, clock: Box) -> Self { + Self { + clock: Some(clock), + ..self + } + } + #[must_use] pub fn dry_run(self, dry_run: bool) -> Self { Self { dry_run, ..self } diff --git a/crates/synd_term/src/application/mod.rs b/crates/synd_term/src/application/mod.rs index 0ef746bb..d4dd8400 100644 --- a/crates/synd_term/src/application/mod.rs +++ b/crates/synd_term/src/application/mod.rs @@ -22,7 +22,7 @@ use crate::{ application::event::KeyEventResult, auth::{self, AuthenticationProvider, Credential, CredentialError, Verified}, client::{ - github::{FetchNotificationInclude, FetchNotificationsParams, GithubClient}, + github::{FetchNotificationsParams, GithubClient}, mutation::subscribe_feed::SubscribeFeedInput, Client, SyndApiError, }, @@ -55,7 +55,7 @@ use input_parser::InputParser; pub use auth::authenticator::{Authenticator, DeviceFlows, JwtService}; mod clock; -pub(crate) use clock::{Clock, SystemClock}; +pub use clock::{Clock, SystemClock}; mod cache; pub use cache::Cache; @@ -120,6 +120,7 @@ impl Application { theme, authenticator, interactor, + clock, dry_run, } = builder; @@ -138,7 +139,7 @@ impl Application { } Self { - clock: Box::new(SystemClock), + clock: clock.unwrap_or_else(|| Box::new(SystemClock)), terminal, client, github_client, @@ -230,7 +231,7 @@ impl Application { self.config .features .enable_github_notification - .then(|| self.keymaps().enable(KeymapId::Notification)); + .then(|| self.keymaps().enable(KeymapId::GhNotification)); } fn set_credential(&mut self, cred: Verified) { @@ -248,13 +249,9 @@ impl Application { .boxed(), ); if self.config.features.enable_github_notification { - self.jobs.futures.push( - future::ready(Ok(Command::FetchGhNotifications { - page: config::github::INITIAL_PAGE_NUM, - populate: Populate::Replace, - })) - .boxed(), - ); + if let Some(fetch) = self.components.gh_notifications.fetch_next_if_needed() { + self.jobs.futures.push(future::ready(Ok(fetch)).boxed()); + } } } @@ -515,7 +512,7 @@ impl Application { self.keymaps() .disable(KeymapId::Subscription) .disable(KeymapId::Entries) - .disable(KeymapId::Notification); + .disable(KeymapId::GhNotification); match self.components.tabs.move_selection(direction) { Tab::Feeds => { @@ -531,7 +528,7 @@ impl Application { self.keymaps().enable(KeymapId::Entries); } Tab::GitHub => { - self.keymaps().enable(KeymapId::Notification); + self.keymaps().enable(KeymapId::GhNotification); } } self.should_render(); @@ -558,7 +555,7 @@ impl Application { } Command::PromptFeedUnsubscription => { if self.components.subscription.selected_feed().is_some() { - self.components.subscription.show_unsubscribe_popup(true); + self.components.subscription.toggle_unsubscribe_popup(true); self.keymaps().enable(KeymapId::UnsubscribePopupSelection); self.should_render(); } @@ -579,7 +576,7 @@ impl Application { self.should_render(); } Command::CancelFeedUnsubscriptionPopup => { - self.components.subscription.show_unsubscribe_popup(false); + self.components.subscription.toggle_unsubscribe_popup(false); self.keymaps().disable(KeymapId::UnsubscribePopupSelection); self.should_render(); } @@ -625,7 +622,9 @@ impl Application { } Command::MoveFilterRequirement(direction) => { let filterer = self.components.filter.move_requirement(direction); - self.apply_filterer(filterer); + self.apply_filterer(filterer) + .into_iter() + .for_each(|command| queue.push_back(command)); self.should_render(); } Command::ActivateCategoryFilterling => { @@ -647,7 +646,9 @@ impl Application { .components .filter .filterer(self.components.tabs.current().into()); - self.apply_filterer(filterer); + self.apply_filterer(filterer) + .into_iter() + .for_each(|command| queue.push_back(command)); self.should_render(); } } @@ -669,16 +670,20 @@ impl Application { } Command::ActivateAllFilterCategories { lane } => { let filterer = self.components.filter.activate_all_categories_state(lane); - self.apply_filterer(filterer); + self.apply_filterer(filterer) + .into_iter() + .for_each(|command| queue.push_back(command)); self.should_render(); } Command::DeactivateAllFilterCategories { lane } => { let filterer = self.components.filter.deactivate_all_categories_state(lane); - self.apply_filterer(filterer); + self.apply_filterer(filterer) + .into_iter() + .for_each(|command| queue.push_back(command)); self.should_render(); } - Command::FetchGhNotifications { page, populate } => { - self.fetch_gh_notifications(populate, page); + Command::FetchGhNotifications { populate, params } => { + self.fetch_gh_notifications(populate, params); } Command::MoveGhNotification(direction) => { self.components.gh_notifications.move_selection(direction); @@ -696,10 +701,8 @@ impl Application { self.open_notification(); } Command::ReloadGhNotifications => { - self.fetch_gh_notifications( - Populate::Replace, - config::github::INITIAL_PAGE_NUM, - ); + let params = self.components.gh_notifications.reload(); + self.fetch_gh_notifications(Populate::Replace, params); } Command::FetchGhNotificationDetails { contexts } => { self.fetch_gh_notification_details(contexts); @@ -715,6 +718,28 @@ impl Application { self.unsubscribe_gh_thread(); self.mark_gh_notification_as_done(); } + Command::OpenGhNotificationFilterPopup => { + self.components.gh_notifications.open_filter_popup(); + self.keymaps().enable(KeymapId::GhNotificationFilterPopup); + self.keymaps().disable(KeymapId::GhNotification); + self.should_render(); + } + Command::CloseGhNotificationFilterPopup => { + self.components + .gh_notifications + .close_filter_popup() + .into_iter() + .for_each(|command| queue.push_back(command)); + self.keymaps().disable(KeymapId::GhNotificationFilterPopup); + self.keymaps().enable(KeymapId::GhNotification); + self.should_render(); + } + Command::UpdateGhnotificationFilterPopupOptions(updater) => { + self.components + .gh_notifications + .update_filter_options(&updater); + self.should_render(); + } Command::RotateTheme => { self.rotate_theme(); self.should_render(); @@ -1065,22 +1090,16 @@ impl Application { } #[tracing::instrument(skip(self))] - fn fetch_gh_notifications(&mut self, populate: Populate, page: u8) { + fn fetch_gh_notifications(&mut self, populate: Populate, params: FetchNotificationsParams) { let client = self .github_client .clone() .expect("Github client not found, this is a BUG"); let request_seq = self .in_flight - .add(RequestId::FetchGithubNotifications { page }); + .add(RequestId::FetchGithubNotifications { page: params.page }); let fut = async move { - match client - .fetch_notifications(FetchNotificationsParams { - page, - include: FetchNotificationInclude::OnlyUnread, - }) - .await - { + match client.fetch_notifications(params).await { Ok(notifications) => Ok(Command::HandleApiResponse { request_seq, response: ApiResponse::FetchGithubNotifications { @@ -1252,6 +1271,7 @@ impl Application { } impl Application { + #[must_use] fn apply_filterer(&mut self, filterer: Filterer) -> Option { match filterer { Filterer::Feed(filterer) => { diff --git a/crates/synd_term/src/cli/export.rs b/crates/synd_term/src/cli/export.rs index 3f209771..03429a12 100644 --- a/crates/synd_term/src/cli/export.rs +++ b/crates/synd_term/src/cli/export.rs @@ -39,7 +39,6 @@ pub struct ExportCommand { } impl ExportCommand { - #[allow(clippy::unused_self)] pub async fn run(self, endpoint: Url) -> i32 { let err = if self.print_schema { Self::print_json_schema() diff --git a/crates/synd_term/src/cli/mod.rs b/crates/synd_term/src/cli/mod.rs index 6b4da2b7..38141a29 100644 --- a/crates/synd_term/src/cli/mod.rs +++ b/crates/synd_term/src/cli/mod.rs @@ -48,7 +48,7 @@ pub struct Args { #[command(flatten)] pub feed: FeedOptions, #[command(flatten)] - pub experimental: ExperimentalOptions, + pub experimental: GithubOptions, #[arg(hide = true, long = "dry-run", hide_long_help = true)] pub dry_run: bool, } @@ -76,23 +76,27 @@ pub struct FeedOptions { } #[derive(clap::Args, Debug)] -pub struct ExperimentalOptions { - /// GitHub Personal Access Token - #[arg( - long, - hide = true, - hide_long_help = true, - env = "SYND_GH_PAT", - required_if_eq("enable_github_notification", "true") - )] - pub github_pat: Option, +#[command(next_help_heading = "GitHub options")] +pub struct GithubOptions { + /// Enable GitHub notification feature #[arg( action = clap::ArgAction::SetTrue, long, - hide = true, hide_long_help = true, + short = 'G', + visible_alias = "enable-gh", + env = "SYND_ENABLE_GH", default_value_t = false, + default_missing_value = "true", )] pub enable_github_notification: bool, + /// GitHub personal access token to fetch notifications + #[arg( + long, + env = "SYND_GH_PAT", + hide_env_values = true, + required_if_eq("enable_github_notification", "true") + )] + pub github_pat: Option, } #[derive(Subcommand, Debug)] diff --git a/crates/synd_term/src/client/github/mod.rs b/crates/synd_term/src/client/github/mod.rs index 65f26039..2795b12d 100644 --- a/crates/synd_term/src/client/github/mod.rs +++ b/crates/synd_term/src/client/github/mod.rs @@ -5,7 +5,7 @@ use crate::{ config, types::github::{ IssueContext, IssueId, Notification, NotificationContext, NotificationId, - PullRequestContext, PullRequestId, Repository, ThreadId, + PullRequestContext, PullRequestId, RepositoryKey, ThreadId, }, }; @@ -17,12 +17,16 @@ pub struct GithubClient { impl GithubClient { pub fn new(pat: impl Into) -> Self { // TODO: configure timeout - Self { - client: Octocrab::builder() - .personal_token(pat.into()) - .build() - .unwrap(), - } + let octo = Octocrab::builder() + .personal_token(pat.into()) + .build() + .unwrap(); + Self::with(octo) + } + + #[must_use] + pub fn with(client: Octocrab) -> Self { + Self { client } } pub(crate) async fn mark_thread_as_done(&self, id: NotificationId) -> octocrab::Result<()> { @@ -66,22 +70,36 @@ pub(crate) enum FetchNotificationInclude { All, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FetchNotificationParticipating { + /// Fetch only participating notifications + OnlyParticipating, + All, +} + +#[derive(Debug, Clone)] pub(crate) struct FetchNotificationsParams { pub(crate) page: u8, pub(crate) include: FetchNotificationInclude, + pub(crate) participating: FetchNotificationParticipating, } impl GithubClient { #[tracing::instrument(skip(self))] pub(crate) async fn fetch_notifications( &self, - FetchNotificationsParams { page, include }: FetchNotificationsParams, + FetchNotificationsParams { + page, + include, + participating, + }: FetchNotificationsParams, ) -> octocrab::Result> { let mut page = self .client .activity() .notifications() .list() + .participating(participating == FetchNotificationParticipating::OnlyParticipating) .all(include == FetchNotificationInclude::All) .page(page) // 1 Origin .per_page(config::github::NOTIFICATION_PER_PAGE) @@ -116,7 +134,7 @@ impl GithubClient { &self, NotificationContext { id, - repository_key: Repository { name, owner }, + repository_key: RepositoryKey { name, owner }, .. }: NotificationContext, ) -> octocrab::Result { @@ -158,7 +176,7 @@ impl GithubClient { &self, NotificationContext { id, - repository_key: Repository { name, owner }, + repository_key: RepositoryKey { name, owner }, .. }: NotificationContext, ) -> octocrab::Result { diff --git a/crates/synd_term/src/command.rs b/crates/synd_term/src/command.rs index 5936491c..7935bf91 100644 --- a/crates/synd_term/src/command.rs +++ b/crates/synd_term/src/command.rs @@ -6,16 +6,17 @@ use crate::{ application::{Direction, Populate, RequestSequence}, auth::{AuthenticationProvider, Credential, Verified}, client::{ - mutation::subscribe_feed::SubscribeFeedInput, payload, + github::FetchNotificationsParams, mutation::subscribe_feed::SubscribeFeedInput, payload, query::subscription::SubscriptionOutput, SyndApiError, }, types::{ github::{ IssueContext, IssueOrPullRequest, Notification, NotificationId, PullRequestContext, + PullRequestState, Reason, }, Feed, }, - ui::components::filter::FilterLane, + ui::components::{filter::FilterLane, gh_notifications::GhNotificationFilterUpdater}, }; #[derive(Debug, Clone)] @@ -155,18 +156,21 @@ pub(crate) enum Command { // Github notifications FetchGhNotifications { populate: Populate, - page: u8, + params: FetchNotificationsParams, + }, + FetchGhNotificationDetails { + contexts: Vec, }, MoveGhNotification(Direction), MoveGhNotificationFirst, MoveGhNotificationLast, OpenGhNotification, ReloadGhNotifications, - FetchGhNotificationDetails { - contexts: Vec, - }, MarkGhNotificationAsDone, UnsubscribeGhThread, + OpenGhNotificationFilterPopup, + CloseGhNotificationFilterPopup, + UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater), // Error HandleError { @@ -307,28 +311,88 @@ impl Command { pub fn rotate_theme() -> Self { Command::RotateTheme } - pub fn move_up_notification() -> Self { + pub fn move_up_gh_notification() -> Self { Command::MoveGhNotification(Direction::Up) } - pub fn move_down_notification() -> Self { + pub fn move_down_gh_notification() -> Self { Command::MoveGhNotification(Direction::Down) } - pub fn move_notification_first() -> Self { + pub fn move_gh_notification_first() -> Self { Command::MoveGhNotificationFirst } - pub fn move_notification_last() -> Self { + pub fn move_gh_notification_last() -> Self { Command::MoveGhNotificationLast } - pub fn open_notification() -> Self { + pub fn open_gh_notification() -> Self { Command::OpenGhNotification } - pub fn reload_notifications() -> Self { + pub fn reload_gh_notifications() -> Self { Command::ReloadGhNotifications } - pub fn mark_notification_as_done() -> Self { + pub fn mark_gh_notification_as_done() -> Self { Command::MarkGhNotificationAsDone } - pub fn unsubscribe_thread() -> Self { + pub fn unsubscribe_gh_thread() -> Self { Command::UnsubscribeGhThread } + pub fn open_gh_notification_filter_popup() -> Self { + Command::OpenGhNotificationFilterPopup + } + pub fn close_gh_notification_filter_popup() -> Self { + Command::CloseGhNotificationFilterPopup + } + pub fn toggle_gh_notification_filter_popup_include_unread() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_include: true, + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_participating() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_participating: true, + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_visibility_public() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_visilibty_public: true, + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_visibility_private() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_visilibty_private: true, + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_pr_open() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_pull_request_condition: Some(PullRequestState::Open), + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_pr_closed() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_pull_request_condition: Some(PullRequestState::Closed), + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_pr_merged() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_pull_request_condition: Some(PullRequestState::Merged), + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_reason_mentioned() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_reason: Some(Reason::Mention), + ..Default::default() + }) + } + pub fn toggle_gh_notification_filter_popup_reason_review() -> Self { + Command::UpdateGhnotificationFilterPopupOptions(GhNotificationFilterUpdater { + toggle_reason: Some(Reason::ReviewRequested), + ..Default::default() + }) + } } diff --git a/crates/synd_term/src/keymap/default.rs b/crates/synd_term/src/keymap/default.rs index 81a766dc..147b31c5 100644 --- a/crates/synd_term/src/keymap/default.rs +++ b/crates/synd_term/src/keymap/default.rs @@ -33,17 +33,36 @@ pub fn default() -> KeymapsConfig { "e" => move_subscribed_feed_last, }, }); - let notification = keymap!({ - "k" | "up" => move_up_notification, - "j" | "down" => move_down_notification, - "enter" => open_notification, - "r" => reload_notifications, - "d" => mark_notification_as_done, - "u" => unsubscribe_thread, + let gh_notification = keymap!({ + "k" | "up" => move_up_gh_notification, + "j" | "down" => move_down_gh_notification, + "enter" => open_gh_notification, + "r" => reload_gh_notifications, + "d" => mark_gh_notification_as_done, + "u" => unsubscribe_gh_thread, "g" => { - "g" => move_notification_first, - "e" => move_notification_last, + "g" => move_gh_notification_first, + "e" => move_gh_notification_last, }, + "f" => open_gh_notification_filter_popup, + }); + let gh_notification_filter_popup = keymap!({ + "u" => toggle_gh_notification_filter_popup_include_unread, + "p" => { + "a" => toggle_gh_notification_filter_popup_participating, + "o" => toggle_gh_notification_filter_popup_pr_open, + "m" => toggle_gh_notification_filter_popup_pr_merged, + "c" => toggle_gh_notification_filter_popup_pr_closed, + "b" => toggle_gh_notification_filter_popup_visibility_public, + "r" => toggle_gh_notification_filter_popup_visibility_private, + }, + "m" => { + "e" => toggle_gh_notification_filter_popup_reason_mentioned, + }, + "r" => { + "r" => toggle_gh_notification_filter_popup_reason_review, + }, + "esc" | "enter" => close_gh_notification_filter_popup, }); let filter = keymap!({ "h" | "left" => move_filter_requirement_left, @@ -68,7 +87,8 @@ pub fn default() -> KeymapsConfig { tabs, entries, subscription, - notification, + gh_notification, + gh_notification_filter_popup, filter, unsubscribe_popup, global, diff --git a/crates/synd_term/src/keymap/mod.rs b/crates/synd_term/src/keymap/mod.rs index e5bef224..82b7aed7 100644 --- a/crates/synd_term/src/keymap/mod.rs +++ b/crates/synd_term/src/keymap/mod.rs @@ -16,10 +16,11 @@ pub(crate) enum KeymapId { Tabs = 2, Entries = 3, Subscription = 4, - Notification = 5, + GhNotification = 5, Filter = 6, CategoryFiltering = 7, UnsubscribePopupSelection = 8, + GhNotificationFilterPopup = 9, } #[derive(Debug)] @@ -74,7 +75,8 @@ pub(crate) struct KeymapsConfig { pub tabs: KeyTrie, pub entries: KeyTrie, pub subscription: KeyTrie, - pub notification: KeyTrie, + pub gh_notification: KeyTrie, + pub gh_notification_filter_popup: KeyTrie, pub filter: KeyTrie, pub unsubscribe_popup: KeyTrie, pub global: KeyTrie, @@ -100,13 +102,17 @@ impl Keymaps { Keymap::new(KeymapId::Tabs, config.tabs), Keymap::new(KeymapId::Entries, config.entries), Keymap::new(KeymapId::Subscription, config.subscription), - Keymap::new(KeymapId::Notification, config.notification), + Keymap::new(KeymapId::GhNotification, config.gh_notification), Keymap::new(KeymapId::Filter, config.filter), Keymap::new(KeymapId::CategoryFiltering, KeyTrie::default()), Keymap::new( KeymapId::UnsubscribePopupSelection, config.unsubscribe_popup, ), + Keymap::new( + KeymapId::GhNotificationFilterPopup, + config.gh_notification_filter_popup, + ), ]; Self { keymaps } diff --git a/crates/synd_term/src/main.rs b/crates/synd_term/src/main.rs index 246d10f5..ca9bf236 100644 --- a/crates/synd_term/src/main.rs +++ b/crates/synd_term/src/main.rs @@ -4,7 +4,7 @@ use anyhow::Context as _; use futures_util::TryFutureExt; use synd_term::{ application::{Application, Cache, Config, Features}, - cli::{self, ApiOptions, Args, ExperimentalOptions, FeedOptions, Palette}, + cli::{self, ApiOptions, Args, FeedOptions, GithubOptions, Palette}, client::{github::GithubClient, Client}, config::{self, Categories}, terminal::{self, Terminal}, @@ -67,10 +67,10 @@ fn build_app( entries_limit, }: FeedOptions, cache_dir: PathBuf, - ExperimentalOptions { + GithubOptions { github_pat, enable_github_notification, - }: ExperimentalOptions, + }: GithubOptions, dry_run: bool, ) -> anyhow::Result { let mut builder = Application::builder() diff --git a/crates/synd_term/src/matcher.rs b/crates/synd_term/src/matcher.rs index d061c18b..81c333c9 100644 --- a/crates/synd_term/src/matcher.rs +++ b/crates/synd_term/src/matcher.rs @@ -13,6 +13,12 @@ pub struct Matcher { buf: Rc>>, } +impl Default for Matcher { + fn default() -> Self { + Self::new() + } +} + impl Matcher { pub fn new() -> Self { Self { diff --git a/crates/synd_term/src/types/github.rs b/crates/synd_term/src/types/github.rs index cace9ea9..874cda6c 100644 --- a/crates/synd_term/src/types/github.rs +++ b/crates/synd_term/src/types/github.rs @@ -54,17 +54,30 @@ impl Deref for PullRequestId { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RepoVisibility { + Public, + Private, +} + +#[derive(Debug, Clone)] +pub(crate) struct RepositoryKey { + pub(crate) name: String, + pub(crate) owner: String, +} + #[derive(Debug, Clone)] pub(crate) struct Repository { pub(crate) name: String, pub(crate) owner: String, + pub(crate) visibility: RepoVisibility, } #[derive(Debug, Clone)] pub(crate) struct NotificationContext { pub(crate) id: ID, pub(crate) notification_id: NotificationId, - pub(crate) repository_key: Repository, + pub(crate) repository_key: RepositoryKey, } pub(crate) type IssueOrPullRequest = @@ -86,11 +99,25 @@ pub(crate) enum SubjectContext { PullRequest(PullRequestContext), } +/// `https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-notification-reasons` +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Reason { + Assign, + Author, + CiActivity, + ManuallySubscribed, + Mention, + TeamMention, + ReviewRequested, + WatchingRepo, + Other(String), +} + #[derive(Clone, Debug)] pub(crate) struct Notification { pub(crate) id: NotificationId, pub(crate) thread_id: Option, - pub(crate) reason: String, + pub(crate) reason: Reason, #[allow(unused)] pub(crate) unread: bool, pub(crate) updated_at: Time, @@ -128,7 +155,15 @@ impl From for Notification { tracing::warn!("Repository full_name not found"); (String::new(), repository.name) }; - let repository = Repository { name, owner }; + let repository = Repository { + name, + owner, + visibility: if repository.private.unwrap_or(false) { + RepoVisibility::Private + } else { + RepoVisibility::Public + }, + }; // Assume url is like "https://api.github.com/notifications/threads/11122223333" let thread_id = url @@ -153,6 +188,18 @@ impl From for Notification { } }; + let reason = match reason.as_str() { + "assign" => Reason::Assign, + "author" => Reason::Author, + "ci_activity" => Reason::CiActivity, + "manual" => Reason::ManuallySubscribed, + "mention" => Reason::Mention, + "team_mention" => Reason::TeamMention, + "review_requested" => Reason::ReviewRequested, + "subscribed" => Reason::WatchingRepo, + other => Reason::Other(other.to_owned()), + }; + Self { id, thread_id, @@ -268,12 +315,12 @@ impl Notification { SubjectType::Issue => Some(Either::Left(NotificationContext { id: self.issue_id()?, notification_id: self.id, - repository_key: self.repository().clone(), + repository_key: self.repository_key().clone(), })), SubjectType::PullRequest => Some(Either::Right(NotificationContext { id: self.pull_request_id()?, notification_id: self.id, - repository_key: self.repository().clone(), + repository_key: self.repository_key().clone(), })), // Currently ignore ci, release, discussion _ => None, @@ -322,8 +369,11 @@ impl Notification { .map(PullRequestId) } - fn repository(&self) -> &Repository { - &self.repository + fn repository_key(&self) -> RepositoryKey { + RepositoryKey { + name: self.repository.name.clone(), + owner: self.repository.owner.clone(), + } } fn comment_id(&self) -> Option { @@ -514,7 +564,7 @@ pub(crate) struct PullRequestContext { author: Option, #[allow(unused)] topics: Vec, - state: PullRequestState, + pub(crate) state: PullRequestState, is_draft: bool, body: String, last_comment: Option, @@ -571,8 +621,6 @@ impl From for PullRequestContext { }) .collect(); - tracing::debug!("{labels:?}"); - Self { author, topics, diff --git a/crates/synd_term/src/ui/components/collections/filterable.rs b/crates/synd_term/src/ui/components/collections/filterable.rs index 4822bfbe..184ac0f5 100644 --- a/crates/synd_term/src/ui/components/collections/filterable.rs +++ b/crates/synd_term/src/ui/components/collections/filterable.rs @@ -103,6 +103,14 @@ where self.refresh(); } + pub(crate) fn with_filter(&mut self, f: With) + where + With: FnOnce(&mut F), + { + f(&mut self.filterer); + self.refresh(); + } + pub(crate) fn retain(&mut self, cond: C) where C: Fn(&T) -> bool, diff --git a/crates/synd_term/src/ui/components/filter/category.rs b/crates/synd_term/src/ui/components/filter/category.rs index 1b4f31df..60a2f11e 100644 --- a/crates/synd_term/src/ui/components/filter/category.rs +++ b/crates/synd_term/src/ui/components/filter/category.rs @@ -5,7 +5,11 @@ use synd_feed::types::Category; use crate::{ application::Populate, config::{Categories, Icon}, - ui::{self}, + types::{self, github::Notification}, + ui::{ + self, + components::filter::{Composable, FilterResult, Filterable}, + }, }; #[allow(dead_code)] @@ -121,3 +125,49 @@ impl CategoriesState { self.state.clear(); } } + +#[derive(Default, Clone, Debug)] +pub(crate) struct CategoryFilterer { + state: HashMap, FilterCategoryState>, +} + +impl Composable for CategoryFilterer {} + +impl CategoryFilterer { + pub(crate) fn new(state: HashMap, FilterCategoryState>) -> Self { + Self { state } + } + + fn filter_by_category(&self, category: &Category<'_>) -> FilterResult { + match self.state.get(category) { + Some(FilterCategoryState::Inactive) => FilterResult::Discard, + _ => FilterResult::Use, + } + } +} + +impl Filterable for CategoryFilterer { + fn filter(&self, entry: &types::Entry) -> super::FilterResult { + self.filter_by_category(entry.category()) + } +} + +impl Filterable for CategoryFilterer { + fn filter(&self, feed: &types::Feed) -> super::FilterResult { + self.filter_by_category(feed.category()) + } +} + +impl Filterable for CategoryFilterer { + fn filter(&self, n: &Notification) -> super::FilterResult { + if !self.state.is_empty() + && n.categories() + .filter_map(|c| self.state.get(c)) + .all(|state| !state.is_active()) + { + FilterResult::Discard + } else { + FilterResult::Use + } + } +} diff --git a/crates/synd_term/src/ui/components/filter/composed.rs b/crates/synd_term/src/ui/components/filter/composed.rs new file mode 100644 index 00000000..2458e1f5 --- /dev/null +++ b/crates/synd_term/src/ui/components/filter/composed.rs @@ -0,0 +1,50 @@ +use crate::ui::components::filter::{FilterResult, Filterable}; + +#[derive(Default, Debug, Clone)] +pub(crate) struct ComposedFilterer { + left: L, + right: R, +} + +impl ComposedFilterer { + pub(crate) fn new(left: L, right: R) -> Self { + Self { left, right } + } + + pub(crate) fn update_left(&mut self, left: L) { + self.left = left; + } + + pub(crate) fn update_right(&mut self, right: R) { + self.right = right; + } + + pub(crate) fn and_then(self, right: F) -> ComposedFilterer { + ComposedFilterer::new(self, right) + } +} + +impl Filterable for ComposedFilterer +where + L: Filterable, + R: Filterable, +{ + fn filter(&self, item: &T) -> super::FilterResult { + if self.left.filter(item) == FilterResult::Use + && self.right.filter(item) == FilterResult::Use + { + FilterResult::Use + } else { + FilterResult::Discard + } + } +} + +pub(crate) trait Composable { + fn and_then(self, right: F) -> ComposedFilterer + where + Self: Sized, + { + ComposedFilterer::new(self, right) + } +} diff --git a/crates/synd_term/src/ui/components/filter/feed.rs b/crates/synd_term/src/ui/components/filter/feed.rs index 45d5b042..b6ff07cb 100644 --- a/crates/synd_term/src/ui/components/filter/feed.rs +++ b/crates/synd_term/src/ui/components/filter/feed.rs @@ -1,13 +1,10 @@ -use std::collections::HashMap; - -use synd_feed::types::{Category, Requirement}; +use synd_feed::types::Requirement; use crate::{ - matcher::Matcher, types, ui::components::filter::{ - category::{CategoriesState, FilterCategoryState}, - FilterResult, Filterable, + category::CategoriesState, composed::Composable, CategoryFilterer, ComposedFilterer, + FilterResult, Filterable, MatcherFilterer, }, }; @@ -28,71 +25,43 @@ impl FeedHandler { } #[derive(Clone, Debug)] -pub(crate) struct FeedFilterer { - pub(super) requirement: Requirement, - pub(super) categories: HashMap, FilterCategoryState>, - pub(super) matcher: Matcher, +pub(crate) struct RequirementFilterer { + requirement: Requirement, } -impl Default for FeedFilterer { +impl Default for RequirementFilterer { fn default() -> Self { - Self { - requirement: FeedHandler::INITIAL_REQUIREMENT, - categories: HashMap::new(), - matcher: Matcher::new(), - } + Self::new(FeedHandler::INITIAL_REQUIREMENT) } } -impl Filterable for FeedFilterer { - fn filter(&self, entry: &types::Entry) -> FilterResult { - self.filter_entry(entry) - } -} +impl Composable for RequirementFilterer {} -impl Filterable for FeedFilterer { - fn filter(&self, feed: &types::Feed) -> FilterResult { - self.filter_feed(feed) +impl RequirementFilterer { + pub(super) fn new(requirement: Requirement) -> Self { + Self { requirement } } } -impl FeedFilterer { - pub fn filter_entry(&self, entry: &types::Entry) -> FilterResult { - if !entry.requirement().is_satisfied(self.requirement) { - return FilterResult::Discard; - } - if let Some(FilterCategoryState::Inactive) = self.categories.get(entry.category()) { - return FilterResult::Discard; - } - if self - .matcher - .r#match(entry.title.as_deref().unwrap_or_default()) - || self - .matcher - .r#match(entry.feed_title.as_deref().unwrap_or_default()) - { +impl Filterable for RequirementFilterer { + fn filter(&self, entry: &types::Entry) -> FilterResult { + if entry.requirement().is_satisfied(self.requirement) { FilterResult::Use } else { FilterResult::Discard } } +} - pub fn filter_feed(&self, feed: &types::Feed) -> FilterResult { - if !feed.requirement().is_satisfied(self.requirement) { - return FilterResult::Discard; - } - if let Some(FilterCategoryState::Inactive) = self.categories.get(feed.category()) { - return FilterResult::Discard; - } - if self - .matcher - .r#match(feed.title.as_deref().unwrap_or_default()) - || self - .matcher - .r#match(feed.website_url.as_deref().unwrap_or_default()) - { - return FilterResult::Use; +impl Filterable for RequirementFilterer { + fn filter(&self, feed: &types::Feed) -> FilterResult { + if feed.requirement().is_satisfied(self.requirement) { + FilterResult::Use + } else { + FilterResult::Discard } - FilterResult::Discard } } + +pub(crate) type FeedFilterer = + ComposedFilterer, MatcherFilterer>; diff --git a/crates/synd_term/src/ui/components/filter/github.rs b/crates/synd_term/src/ui/components/filter/github.rs index 7bce9fce..488174e3 100644 --- a/crates/synd_term/src/ui/components/filter/github.rs +++ b/crates/synd_term/src/ui/components/filter/github.rs @@ -1,13 +1,4 @@ -use std::collections::HashMap; -use synd_feed::types::Category; - -use crate::{ - types::github::Notification, - ui::components::filter::{ - category::{CategoriesState, FilterCategoryState}, - FilterResult, Filterable, - }, -}; +use crate::ui::components::filter::category::CategoriesState; #[derive(Debug)] pub(super) struct GhNotificationHandler { @@ -15,27 +6,9 @@ pub(super) struct GhNotificationHandler { } impl GhNotificationHandler { - pub(super) fn new() -> Self { + pub(crate) fn new() -> Self { Self { categories_state: CategoriesState::new(), } } } - -#[derive(Clone, Debug, Default)] -pub(crate) struct GhNotificationFilterer { - pub(super) categories: HashMap, FilterCategoryState>, -} - -impl Filterable for GhNotificationFilterer { - fn filter(&self, n: &Notification) -> FilterResult { - if !self.categories.is_empty() - && n.categories() - .filter_map(|c| self.categories.get(c)) - .all(|state| !state.is_active()) - { - return FilterResult::Discard; - } - FilterResult::Use - } -} diff --git a/crates/synd_term/src/ui/components/filter/matcher.rs b/crates/synd_term/src/ui/components/filter/matcher.rs new file mode 100644 index 00000000..6e210948 --- /dev/null +++ b/crates/synd_term/src/ui/components/filter/matcher.rs @@ -0,0 +1,61 @@ +use crate::{ + matcher::Matcher, + types::{self, github::Notification}, + ui::components::filter::{FilterResult, Filterable}, +}; + +#[derive(Default, Clone, Debug)] +pub(crate) struct MatcherFilterer { + matcher: Matcher, +} + +impl MatcherFilterer { + pub(crate) fn new(matcher: Matcher) -> Self { + Self { matcher } + } +} + +impl Filterable for MatcherFilterer { + fn filter(&self, entry: &types::Entry) -> super::FilterResult { + if self + .matcher + .r#match(entry.title.as_deref().unwrap_or_default()) + || self + .matcher + .r#match(entry.feed_title.as_deref().unwrap_or_default()) + { + FilterResult::Use + } else { + FilterResult::Discard + } + } +} + +impl Filterable for MatcherFilterer { + fn filter(&self, feed: &types::Feed) -> super::FilterResult { + if self + .matcher + .r#match(feed.title.as_deref().unwrap_or_default()) + || self + .matcher + .r#match(feed.website_url.as_deref().unwrap_or_default()) + { + FilterResult::Use + } else { + FilterResult::Discard + } + } +} + +impl Filterable for MatcherFilterer { + fn filter(&self, n: &Notification) -> super::FilterResult { + if self.matcher.r#match(n.title()) + || self.matcher.r#match(&n.repository.owner) + || self.matcher.r#match(&n.repository.name) + { + FilterResult::Use + } else { + FilterResult::Discard + } + } +} diff --git a/crates/synd_term/src/ui/components/filter/mod.rs b/crates/synd_term/src/ui/components/filter/mod.rs index a6fb95ca..a52b422d 100644 --- a/crates/synd_term/src/ui/components/filter/mod.rs +++ b/crates/synd_term/src/ui/components/filter/mod.rs @@ -1,6 +1,7 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use itertools::Itertools; use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Rect}, @@ -12,17 +13,24 @@ use synd_feed::types::{Category, Requirement}; use crate::{ application::{Direction, Populate}, + client::github::{FetchNotificationInclude, FetchNotificationParticipating}, command::Command, config::Categories, keymap::{KeyTrie, Keymap}, matcher::Matcher, - types::{self, RequirementExt}, + types::{ + self, + github::{PullRequestState, Reason, RepoVisibility}, + RequirementExt, + }, ui::{ components::{ filter::{ category::{CategoriesState, FilterCategoryState}, + feed::RequirementFilterer, github::GhNotificationHandler, }, + gh_notifications::GhNotificationFilterOptions, tabs::Tab, }, icon, @@ -35,9 +43,15 @@ mod feed; pub(crate) use feed::{FeedFilterer, FeedHandler}; mod github; -pub(crate) use github::GhNotificationFilterer; mod category; +pub(crate) use category::CategoryFilterer; + +mod composed; +pub(crate) use composed::{Composable, ComposedFilterer}; + +mod matcher; +pub(crate) use matcher::MatcherFilterer; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FilterLane { @@ -54,10 +68,12 @@ impl From for FilterLane { } } +pub(crate) type CategoryAndMatcherFilterer = ComposedFilterer; + #[derive(Clone, Debug)] pub(crate) enum Filterer { Feed(FeedFilterer), - GhNotification(GhNotificationFilterer), + GhNotification(CategoryAndMatcherFilterer), } pub(crate) trait Filterable { @@ -211,6 +227,7 @@ impl Filter { self.filterer(lane) } + #[must_use] pub(crate) fn filterer(&self, lane: FilterLane) -> Filterer { match lane { @@ -221,32 +238,33 @@ impl Filter { #[must_use] fn feed_filterer(&self) -> FeedFilterer { - let mut matcher = self.matcher.clone(); - matcher.update_needle(self.prompt.borrow().line()); - FeedFilterer { - requirement: self.feed.requirement, - categories: self - .feed - .categories_state - .state - .iter() - .map(|(c, state)| (c.clone(), state.state)) - .collect(), - matcher, - } + RequirementFilterer::new(self.feed.requirement) + .and_then(Self::category_filterer(&self.feed.categories_state)) + .and_then(self.matcher_filterer()) } #[must_use] - fn gh_notification_filterer(&self) -> GhNotificationFilterer { - GhNotificationFilterer { - categories: self - .gh_notification - .categories_state + fn gh_notification_filterer(&self) -> CategoryAndMatcherFilterer { + Self::category_filterer(&self.gh_notification.categories_state) + .and_then(self.matcher_filterer()) + } + + #[must_use] + fn category_filterer(categories: &CategoriesState) -> CategoryFilterer { + CategoryFilterer::new( + categories .state .iter() .map(|(c, state)| (c.clone(), state.state)) .collect(), - } + ) + } + + #[must_use] + fn matcher_filterer(&self) -> MatcherFilterer { + let mut matcher = self.matcher.clone(); + matcher.update_needle(self.prompt.borrow().line()); + MatcherFilterer::new(matcher) } pub fn update_categories( @@ -278,8 +296,13 @@ impl Filter { } } +pub(super) struct FilterContext<'a> { + pub(super) ui: &'a Context<'a>, + pub(super) gh_options: &'a GhNotificationFilterOptions, +} + impl Filter { - pub fn render(&self, area: Rect, buf: &mut Buffer, cx: &Context<'_>) { + pub(super) fn render(&self, area: Rect, buf: &mut Buffer, cx: &FilterContext<'_>) { let area = Block::new() .padding(Padding { left: 2, @@ -291,30 +314,97 @@ impl Filter { let vertical = Layout::vertical([Constraint::Length(2), Constraint::Length(1)]); let [filter_area, search_area] = vertical.areas(area); - self.render_filter(filter_area, buf, cx); - self.render_search(search_area, buf, cx); + let lane = cx.ui.tab.into(); + self.render_filter(filter_area, buf, cx, lane); + self.render_search(search_area, buf, cx.ui, lane); } - fn render_filter(&self, area: Rect, buf: &mut Buffer, cx: &Context<'_>) { - let horizontal = Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]); - let [requirement_area, categories_area] = horizontal.areas(area); + #[allow(unstable_name_collisions)] + fn render_filter( + &self, + area: Rect, + buf: &mut Buffer, + cx: &FilterContext<'_>, + lane: FilterLane, + ) { + let mut spans = vec![Span::from(concat!(icon!(filter), " Filter")).dim()]; - let spans = vec![ - Span::from(concat!(icon!(filter), " Filter")).dim(), - Span::from(" "), - { - let r = self.feed.requirement.label(&cx.theme.requirement); + match lane { + FilterLane::Feed => { + let mut r = self.feed.requirement.label(&cx.ui.theme.requirement); if r.content == "MAY" { - r.dim() - } else { - r + r = r.dim(); + } + spans.extend([Span::from(" "), r, Span::from(" ")]); + } + FilterLane::GhNotification => { + let options = cx.gh_options; + let mut unread = Span::from("Unread"); + if options.include == FetchNotificationInclude::All { + unread = unread.dim(); + }; + + let mut participating = Span::from("Participating"); + if options.participating == FetchNotificationParticipating::All { + participating = participating.dim(); } - }, - Span::from(" "), - ]; - Line::from(spans).render(requirement_area, buf); - let lane = cx.tab.into(); + let visibility = match options.visibility { + Some(RepoVisibility::Public) => Some(Span::from("Public")), + Some(RepoVisibility::Private) => Some(Span::from("Private")), + None => None, + }; + + spans.extend([ + Span::from(" "), + unread, + Span::from(" "), + participating, + Span::from(" "), + ]); + if let Some(visibility) = visibility { + spans.extend([visibility, Span::from(" ")]); + } + + let pr_conditions = options + .pull_request_conditions + .iter() + .map(|cond| match cond { + PullRequestState::Open => Span::from("Open"), + PullRequestState::Merged => Span::from("Merged"), + PullRequestState::Closed => Span::from("Closed"), + }) + .collect::>(); + if !pr_conditions.is_empty() { + spans.extend(pr_conditions.into_iter().intersperse(Span::from(" "))); + spans.push(Span::from(" ")); + } + + let reasons = options + .reasons + .iter() + .filter_map(|reason| match reason { + Reason::Mention | Reason::TeamMention => Some(Span::from("Mentioned")), + Reason::ReviewRequested => Some(Span::from("ReviewRequested")), + _ => None, + }) + .collect::>(); + if !reasons.is_empty() { + spans.extend(reasons.into_iter().intersperse(Span::from(" "))); + spans.push(Span::from(" ")); + } + } + } + let status_line = Line::from(spans); + #[allow(clippy::cast_possible_truncation)] + let horizontal = Layout::horizontal([ + Constraint::Length(status_line.width() as u16), + Constraint::Fill(1), + ]); + let [status_area, categories_area] = horizontal.areas(area); + + status_line.render(status_area, buf); + let (categories, categories_state) = match lane { FilterLane::Feed => ( &self.feed.categories_state.categories, @@ -362,14 +452,20 @@ impl Filter { Line::from(spans).render(categories_area, buf); } - fn render_search(&self, area: Rect, buf: &mut Buffer, _cx: &Context<'_>) { + fn render_search(&self, area: Rect, buf: &mut Buffer, _cx: &Context<'_>, lane: FilterLane) { let mut spans = vec![]; let mut label = Span::from(concat!(icon!(search), " Search")); if self.state != State::SearchFiltering { label = label.dim(); } spans.push(label); - spans.push(Span::from(" ")); + { + let padding = match lane { + FilterLane::Feed => " ", + FilterLane::GhNotification => " ", + }; + spans.push(Span::from(padding)); + } let search = Line::from(spans); let margin = search.width() + 1; @@ -401,17 +497,15 @@ mod tests { fn filter_match_feed_url() { let mut matcher = Matcher::new(); matcher.update_needle("ymgyt"); - let filter = FeedFilterer { - requirement: Requirement::May, - categories: HashMap::new(), - matcher, - }; + let filter = RequirementFilterer::new(Requirement::May) + .and_then(CategoryFilterer::new(HashMap::new())) + .and_then(MatcherFilterer::new(matcher)); let mut feed: Feed = Faker.fake(); // title does not match needle feed.title = Some("ABC".into()); feed.website_url = Some("https://blog.ymgyt.io".into()); - assert_eq!(filter.filter_feed(&feed), FilterResult::Use); + assert_eq!(filter.filter(&feed), FilterResult::Use); } } diff --git a/crates/synd_term/src/ui/components/gh_notifications/filter_popup.rs b/crates/synd_term/src/ui/components/gh_notifications/filter_popup.rs new file mode 100644 index 00000000..9c51e5e7 --- /dev/null +++ b/crates/synd_term/src/ui/components/gh_notifications/filter_popup.rs @@ -0,0 +1,273 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Padding, Widget}, +}; + +use crate::{ + client::github::{FetchNotificationInclude, FetchNotificationParticipating}, + types::github::{Notification, PullRequestState, Reason, RepoVisibility, SubjectContext}, + ui::{ + components::{ + filter::{FilterResult, Filterable}, + gh_notifications::{ + GhNotificationFilterOptions, GhNotificationFilterOptionsState, + GhNotificationFilterUpdater, + }, + }, + icon, Context, + }, +}; + +#[derive(Clone, Debug, Default)] +pub(super) struct OptionFilterer { + options: GhNotificationFilterOptions, +} + +impl OptionFilterer { + pub(super) fn new(options: GhNotificationFilterOptions) -> Self { + Self { options } + } +} + +impl Filterable for OptionFilterer { + fn filter(&self, n: &Notification) -> FilterResult { + // unread and participating are handled in rest api + if let Some(visibility) = self.options.visibility { + if visibility != n.repository.visibility { + return FilterResult::Discard; + } + } + if !self.options.pull_request_conditions.is_empty() { + match n.subject_context.as_ref() { + Some(SubjectContext::PullRequest(pr)) => { + if !self.options.pull_request_conditions.contains(&pr.state) { + return FilterResult::Discard; + } + } + _ => return FilterResult::Discard, + } + } + if !self.options.reasons.is_empty() { + let mut ok = false; + for reason in &self.options.reasons { + if (reason == &Reason::Mention + && (n.reason == Reason::TeamMention || n.reason == Reason::Mention)) + || reason == &n.reason + { + ok = true; + } + } + if !ok { + return FilterResult::Discard; + } + } + FilterResult::Use + } +} + +pub(super) struct FilterPopup { + pub(super) is_active: bool, + options: GhNotificationFilterOptions, + pending_options: Option, +} + +impl FilterPopup { + pub(super) fn new(options: GhNotificationFilterOptions) -> Self { + Self { + is_active: false, + options, + pending_options: None, + } + } + + pub(super) fn applied_options(&self) -> &GhNotificationFilterOptions { + &self.options + } + + pub(super) fn commit(&mut self) -> GhNotificationFilterOptionsState { + if let Some(options) = self.pending_options.take() { + let org = std::mem::replace(&mut self.options, options); + if org != self.options { + return GhNotificationFilterOptionsState::Changed(self.options.clone()); + } + } + GhNotificationFilterOptionsState::Unchanged + } + + pub(super) fn update_options(&mut self, new: &GhNotificationFilterUpdater) { + let mut pending = self + .pending_options + .take() + .unwrap_or_else(|| self.options.clone()); + + if new.toggle_include { + pending.include = match pending.include { + FetchNotificationInclude::OnlyUnread => FetchNotificationInclude::All, + FetchNotificationInclude::All => FetchNotificationInclude::OnlyUnread, + }; + } + if new.toggle_participating { + pending.participating = match pending.participating { + FetchNotificationParticipating::OnlyParticipating => { + FetchNotificationParticipating::All + } + FetchNotificationParticipating::All => { + FetchNotificationParticipating::OnlyParticipating + } + }; + } + if new.toggle_visilibty_public { + pending.visibility = match pending.visibility { + Some(RepoVisibility::Public) => None, + Some(RepoVisibility::Private) | None => Some(RepoVisibility::Public), + }; + } + if new.toggle_visilibty_private { + pending.visibility = match pending.visibility { + Some(RepoVisibility::Private) => None, + Some(RepoVisibility::Public) | None => Some(RepoVisibility::Private), + }; + } + if let Some(pr_state) = new.toggle_pull_request_condition { + pending.toggle_pull_request_condition(pr_state); + } + if let Some(reason) = new.toggle_reason.as_ref() { + pending.toggle_reason(reason); + } + + self.pending_options = Some(pending); + } +} + +impl FilterPopup { + #[allow(clippy::too_many_lines)] + pub(super) fn render(&self, area: Rect, buf: &mut Buffer, cx: &Context<'_>) { + let area = { + let block = Block::new() + .title_top("Filter") + .title_alignment(Alignment::Center) + .title_style(Style::new().add_modifier(Modifier::BOLD)) + .padding(Padding { + left: 2, + right: 2, + top: 2, + bottom: 2, + }) + .borders(Borders::ALL) + .style(cx.theme.base); + let inner_area = block.inner(area); + block.render(area, buf); + inner_area + }; + + let vertical = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + // Constraint::Fill(1), + ]); + let [status_area, participating_area, visibility_area, pull_request_area, reason_area] = + vertical.areas(area); + let options = self.pending_options.as_ref().unwrap_or(&self.options); + + // Render status + { + let mut spans = vec![ + Span::from(concat!(icon!(unread), " Status")).bold(), + Span::from(" "), + ]; + let mut unread = Span::from("Unread(u)").underlined(); + if options.include == FetchNotificationInclude::All { + unread = unread.dim().not_underlined(); + } + spans.push(unread); + + Line::from(spans).render(status_area, buf); + } + + // Render participating + { + let mut spans = vec![ + Span::from(concat!(icon!(chat), " Participating")).bold(), + Span::from(" "), + ]; + let mut participating = Span::from("Participating(pa)").underlined(); + if options.participating == FetchNotificationParticipating::All { + participating = participating.dim().not_underlined(); + } + spans.push(participating); + Line::from(spans).render(participating_area, buf); + } + + // Render repository visibility + { + let mut spans = vec![ + Span::from(concat!(icon!(repository), " Repository")).bold(), + Span::from(" "), + ]; + let mut public = Span::from("Public(pb)").dim(); + let mut private = Span::from("Private(pr)").dim(); + match options.visibility { + Some(RepoVisibility::Public) => public = public.not_dim().underlined(), + Some(RepoVisibility::Private) => private = private.not_dim().underlined(), + None => {} + }; + spans.extend([public, Span::from(" "), private]); + Line::from(spans).render(visibility_area, buf); + } + + // Render pull request conditions + { + let mut spans = vec![ + Span::from(concat!(icon!(pullrequest), " PullRequest")).bold(), + Span::from(" "), + ]; + let mut open = Span::from("Open(po)").dim(); + let mut merged = Span::from("Merged(pm)").dim(); + let mut closed = Span::from("Closed(pc)").dim(); + for cond in &options.pull_request_conditions { + match cond { + PullRequestState::Open => { + open = open.not_dim().underlined(); + } + PullRequestState::Merged => { + merged = merged.not_dim().underlined(); + } + PullRequestState::Closed => { + closed = closed.not_dim().underlined(); + } + } + } + spans.extend([open, Span::from(" "), merged, Span::from(" "), closed]); + Line::from(spans).render(pull_request_area, buf); + } + + // Render reasons + { + let mut spans = vec![ + Span::from(concat!(icon!(chat), " Reason")).bold(), + Span::from(" "), + ]; + let mut mentioned = Span::from("Mentioned(me)").dim(); + let mut review = Span::from("ReviewRequested(rr)").dim(); + for reason in &options.reasons { + match reason { + Reason::Mention | Reason::TeamMention => { + mentioned = mentioned.not_dim().underlined(); + } + Reason::ReviewRequested => { + review = review.not_dim().underlined(); + } + _ => {} + } + } + spans.extend([mentioned, Span::from(" "), review]); + Line::from(spans).render(reason_area, buf); + } + } +} diff --git a/crates/synd_term/src/ui/components/gh_notifications.rs b/crates/synd_term/src/ui/components/gh_notifications/mod.rs similarity index 73% rename from crates/synd_term/src/ui/components/gh_notifications.rs rename to crates/synd_term/src/ui/components/gh_notifications/mod.rs index 9d69164d..0af61bc4 100644 --- a/crates/synd_term/src/ui/components/gh_notifications.rs +++ b/crates/synd_term/src/ui/components/gh_notifications/mod.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, collections::HashMap}; +use std::{borrow::Cow, collections::HashMap, fmt::Debug}; use chrono_humanize::{Accuracy, HumanTime, Tense}; use itertools::Itertools; @@ -15,38 +15,112 @@ use ratatui::{ use crate::{ application::{Direction, Populate}, + client::github::{ + FetchNotificationInclude, FetchNotificationParticipating, FetchNotificationsParams, + }, command::Command, config::{self, Categories}, types::{ github::{ Comment, IssueContext, Notification, NotificationId, PullRequestContext, - SubjectContext, SubjectType, + PullRequestState, Reason, RepoVisibility, SubjectContext, SubjectType, }, TimeExt, }, ui::{ self, - components::{collections::FilterableVec, filter::GhNotificationFilterer}, + components::{ + collections::FilterableVec, + filter::{CategoryFilterer, ComposedFilterer, MatcherFilterer}, + }, + extension::RectExt, icon, widgets::scrollbar::Scrollbar, Context, }, }; +mod filter_popup; +use filter_popup::{FilterPopup, OptionFilterer}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NotificationStatus { MarkingAsDone, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum GhNotificationFilterOptionsState { + Unchanged, + Changed(GhNotificationFilterOptions), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GhNotificationFilterOptions { + pub(crate) include: FetchNotificationInclude, + pub(crate) participating: FetchNotificationParticipating, + pub(crate) visibility: Option, + pub(crate) pull_request_conditions: Vec, + pub(crate) reasons: Vec, +} + +impl Default for GhNotificationFilterOptions { + fn default() -> Self { + Self { + include: FetchNotificationInclude::OnlyUnread, + participating: FetchNotificationParticipating::All, + visibility: None, + pull_request_conditions: Vec::new(), + reasons: Vec::new(), + } + } +} + +impl GhNotificationFilterOptions { + fn toggle_pull_request_condition(&mut self, pr_state: PullRequestState) { + if let Some(idx) = self + .pull_request_conditions + .iter() + .position(|cond| cond == &pr_state) + { + self.pull_request_conditions.swap_remove(idx); + } else { + self.pull_request_conditions.push(pr_state); + } + } + + fn toggle_reason(&mut self, reason: &Reason) { + if let Some(idx) = self.reasons.iter().position(|r| r == reason) { + self.reasons.swap_remove(idx); + } else { + self.reasons.push(reason.clone()); + } + } +} + +#[allow(clippy::struct_excessive_bools, clippy::struct_field_names)] +#[derive(Debug, Clone, Default)] +pub(crate) struct GhNotificationFilterUpdater { + pub(crate) toggle_include: bool, + pub(crate) toggle_participating: bool, + pub(crate) toggle_visilibty_public: bool, + pub(crate) toggle_visilibty_private: bool, + pub(crate) toggle_pull_request_condition: Option, + pub(crate) toggle_reason: Option, +} + +type CategoryAndMatcherFilterer = ComposedFilterer; + #[allow(clippy::struct_field_names)] pub(crate) struct GhNotifications { max_repository_name: usize, - notifications: FilterableVec, + notifications: + FilterableVec>, #[allow(clippy::zero_sized_map_values)] status: HashMap, limit: usize, next_page: Option, + filter_popup: FilterPopup, } impl GhNotifications { @@ -58,11 +132,21 @@ impl GhNotifications { status: HashMap::new(), limit: config::github::NOTIFICATION_PER_PAGE as usize, next_page: Some(config::github::INITIAL_PAGE_NUM), + filter_popup: FilterPopup::new(GhNotificationFilterOptions::default()), } } - pub(crate) fn update_filterer(&mut self, filterer: GhNotificationFilterer) { - self.notifications.update_filter(filterer); + pub(crate) fn filter_options(&self) -> &GhNotificationFilterOptions { + self.filter_popup.applied_options() + } + + pub(crate) fn update_filter_options(&mut self, updater: &GhNotificationFilterUpdater) { + self.filter_popup.update_options(updater); + } + + pub(crate) fn update_filterer(&mut self, filterer: CategoryAndMatcherFilterer) { + self.notifications + .with_filter(|composed| composed.update_left(filterer)); } pub(crate) fn update_notifications( @@ -101,12 +185,38 @@ impl GhNotifications { pub(crate) fn fetch_next_if_needed(&self) -> Option { match self.next_page { Some(page) if self.notifications.len() < self.limit => { + tracing::debug!( + "Should fetch more. notifications: {} next_page {:?}", + self.notifications.len(), + self.next_page + ); Some(Command::FetchGhNotifications { populate: Populate::Append, - page, + params: self.next_fetch_params(page), }) } - _ => None, + _ => { + tracing::debug!( + "Nothing to fetch. notifications: {} next_page {:?}", + self.notifications.len(), + self.next_page + ); + None + } + } + } + + pub(crate) fn reload(&mut self) -> FetchNotificationsParams { + self.next_page = Some(config::github::INITIAL_PAGE_NUM); + self.next_fetch_params(config::github::INITIAL_PAGE_NUM) + } + + fn next_fetch_params(&self, page: u8) -> FetchNotificationsParams { + let options = self.filter_popup.applied_options(); + FetchNotificationsParams { + page, + include: options.include, + participating: options.participating, } } @@ -164,6 +274,27 @@ impl GhNotifications { self.notifications.move_last(); } + pub(crate) fn open_filter_popup(&mut self) { + self.filter_popup.is_active = true; + } + + #[must_use] + pub(crate) fn close_filter_popup(&mut self) -> Option { + self.filter_popup.is_active = false; + match self.filter_popup.commit() { + GhNotificationFilterOptionsState::Changed(options) => { + let filterer = OptionFilterer::new(options); + self.notifications + .with_filter(|composed| composed.update_right(filterer)); + Some(Command::FetchGhNotifications { + populate: Populate::Replace, + params: self.reload(), + }) + } + GhNotificationFilterOptionsState::Unchanged => None, + } + } + pub(crate) fn selected_notification(&self) -> Option<&Notification> { self.notifications.selected() } @@ -176,6 +307,10 @@ impl GhNotifications { self.render_notifications(notifications_area, buf, cx); self.render_detail(detail_area, buf, cx); + + if self.filter_popup.is_active { + self.render_filter_popup(area, buf, cx); + } } fn render_notifications(&self, area: Rect, buf: &mut Buffer, cx: &Context<'_>) { @@ -256,7 +391,7 @@ impl GhNotifications { let subject = n.title(); let subject_icon = n.subject_icon(); let repo = n.repository.name.as_str(); - let reason = reason_label(n.reason.as_str()); + let reason = reason_label(&n.reason); let is_marking_as_done = self .status @@ -470,41 +605,28 @@ impl GhNotifications { .render(comment_area, buf); } } + + fn render_filter_popup(&self, area: Rect, buf: &mut Buffer, cx: &Context<'_>) { + let area = { + let area = area.centered(60, 30); + area.reset(buf); + area + }; + self.filter_popup.render(area, buf, cx); + } } -// https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28 -fn reason_label(reason: &str) -> &str { +fn reason_label(reason: &Reason) -> &str { match reason { - "approval_requested" => "approval req", - // Assigned to the issue - "assign" => "assigned", - // You created the thread - "author" => "author", - // You commented on the thread - "comment" => "comment", - // A GitHub Actions workflow run that you triggered was completed - "ci_activity" => "ci", - // You accepted an invitation to contriute to the repository - "invitation" => "invitation", - // You subscribed to the thread(via an issue or pull request) - "manual" => "manual", - // Organization members have requested to enable a feature such as Draft Pull Requests or Copilot - "member_feature_requested" => "feature req", - // You wre specifically @mentioned in the content - "mention" => "mentioned", - // You, or a team you're a member of, were requested to review a pull request - "review_requested" => "review", - // GitHub discovered a security vulnerability in your repo - "security_alert" => "security alert", - // You wre credited for contributing to a security advisory - "security_advisory_credit" => "security advisory credit", - // You changed the thread state (for example, closing an issue or merging a PR) - "state_change" => "state change", - // You're watching the repository - "subscribed" => "subscribed", - // You were on a team that was mentioned - "team_mention" => "team mentioned", - etc => etc, + Reason::Assign => "assigned", + Reason::Author => "author", + Reason::CiActivity => "ci", + Reason::ManuallySubscribed => "manual", + Reason::Mention => "mentioned", + Reason::TeamMention => "team mentioned", + Reason::ReviewRequested => "review", + Reason::WatchingRepo => "subscribed", + Reason::Other(other) => other, } } diff --git a/crates/synd_term/src/ui/components/root.rs b/crates/synd_term/src/ui/components/root.rs index 07cf6fad..8718a2d3 100644 --- a/crates/synd_term/src/ui/components/root.rs +++ b/crates/synd_term/src/ui/components/root.rs @@ -4,7 +4,7 @@ use ratatui::{ }; use crate::ui::{ - components::{tabs::Tab, Components}, + components::{filter::FilterContext, tabs::Tab, Components}, Context, }; @@ -30,7 +30,14 @@ impl<'a> Root<'a> { let [tabs_area, filter_area, content_area, prompt_area] = layout.areas(area); self.components.tabs.render(tabs_area, buf, cx); - self.components.filter.render(filter_area, buf, cx); + self.components.filter.render( + filter_area, + buf, + &FilterContext { + ui: cx, + gh_options: self.components.gh_notifications.filter_options(), + }, + ); match cx.tab { Tab::Feeds => self.components.subscription.render(content_area, buf, cx), diff --git a/crates/synd_term/src/ui/components/status.rs b/crates/synd_term/src/ui/components/status.rs index 9574440d..e32f0a1b 100644 --- a/crates/synd_term/src/ui/components/status.rs +++ b/crates/synd_term/src/ui/components/status.rs @@ -55,24 +55,33 @@ impl StatusLine { ("j/k", "󰹹"), ("gg", "󱞧"), ("ge", "󱞥"), - ("h/l", icon!(requirement)), ("c", icon!(category)), ("/", icon!(search)), - ("r", "󰑓"), ][..]; - let suf_keys = &[("q", "")][..]; - let per_screen_keys = match tab { + let suf_keys = &[("r", "󰑓"), ("q", "")][..]; + let per_tab_keys = match tab { Some(Tab::Feeds) => pre_keys .iter() - .chain(&[("Ent", icon!(open)), ("a", "󰑫"), ("e", ""), ("d", "󰼡")]) + .chain(&[ + ("h/l", icon!(requirement)), + ("Ent", icon!(open)), + ("a", "󰑫"), + ("e", ""), + ("d", "󰼡"), + ]) .chain(suf_keys), Some(Tab::Entries) => pre_keys .iter() - .chain(&[("Ent", icon!(open))]) + .chain(&[("h/l", icon!(requirement)), ("Ent", icon!(open))]) .chain(suf_keys), Some(Tab::GitHub) => pre_keys .iter() - .chain(&[("Ent", icon!(open)), ("d", icon!(check)), ("u", "")]) + .chain(&[ + ("f", icon!(filter)), + ("Ent", icon!(open)), + ("d", icon!(check)), + ("u", ""), + ]) .chain(suf_keys), // Imply login None => [("j/k", "󰹹")][..] @@ -81,7 +90,7 @@ impl StatusLine { .chain(&[("q", "")][..]), }; - let spans = per_screen_keys + let spans = per_tab_keys .flat_map(|(key, desc)| { let desc = Span::styled(format!("{key}:{desc} "), cx.theme.prompt.key_desc); [desc] diff --git a/crates/synd_term/src/ui/components/subscription.rs b/crates/synd_term/src/ui/components/subscription.rs index b876b25d..d7a0fec6 100644 --- a/crates/synd_term/src/ui/components/subscription.rs +++ b/crates/synd_term/src/ui/components/subscription.rs @@ -74,7 +74,7 @@ impl Subscription { self.feeds.selected() } - pub(crate) fn show_unsubscribe_popup(&mut self, show: bool) { + pub(crate) fn toggle_unsubscribe_popup(&mut self, show: bool) { if show { self.unsubscribe_popup.selected_feed = self.selected_feed().cloned(); } else { diff --git a/crates/synd_term/src/ui/icon.rs b/crates/synd_term/src/ui/icon.rs index bae38f9a..a9dd6e51 100644 --- a/crates/synd_term/src/ui/icon.rs +++ b/crates/synd_term/src/ui/icon.rs @@ -1,33 +1,35 @@ #[rustfmt::skip] macro_rules! icon { - (feeds) => { "󰑫" }; - (feedsoff) => { "󰑫" }; - (entries) => { "󱉯" }; - (category) => { "" }; - (calendar) => { "" }; - (check) => { "" }; - (comment) => { "" }; - (cross) => { "" }; - (discussion) => { "" }; - (entry) => { "󰯂" }; - (filter) => { "󰈶" }; - (issueopen) => { "" }; - (issuereopened) => { "" }; - (issuenotplanned) => { "" }; - (issueclosed) => { "" }; - (label) => { "󱍵" }; - (requirement) => { "" }; - (open) => { "󰏌" }; - (pullrequest) => { "" }; + (feeds) => { "󰑫" }; + (feedsoff) => { "󰑫" }; + (entries) => { "󱉯" }; + (category) => { "" }; + (calendar) => { "" }; + (chat) => { "󰭻" }; + (check) => { "" }; + (comment) => { "" }; + (cross) => { "" }; + (discussion) => { "" }; + (entry) => { "󰯂" }; + (filter) => { "󰈶" }; + (github) => { "󰊤" }; + (google) => { "󰊭" }; + (issueopen) => { "" }; + (issuereopened) => { "" }; + (issuenotplanned) => { "" }; + (issueclosed) => { "" }; + (label) => { "󱍵" }; + (requirement) => { "" }; + (open) => { "󰏌" }; + (pullrequest) => { "" }; (pullrequestmerged) => { "" }; (pullrequestclosed) => { "" }; - (pullrequestdraft) => { "" }; - (repository) => { "" }; - (search) => { "" }; - (tag) => { "󰓹" }; - (summary) => { "󱙓" }; - (github) => { "󰊤" }; - (google) => { "󰊭" }; + (pullrequestdraft) => { "" }; + (repository) => { "" }; + (search) => { "" }; + (summary) => { "󱙓" }; + (tag) => { "󰓹" }; + (unread) => { "󰮒" }; } pub(crate) use icon; diff --git a/crates/synd_term/tests/integration.rs b/crates/synd_term/tests/integration.rs index 8be375ee..441361a9 100644 --- a/crates/synd_term/tests/integration.rs +++ b/crates/synd_term/tests/integration.rs @@ -2,7 +2,12 @@ mod test { use std::path::{Path, PathBuf}; - use synd_term::{application::Config, auth::Credential, key, shift}; + use chrono::{TimeZone, Utc}; + use synd_term::{ + application::{Config, Features}, + auth::Credential, + key, shift, + }; use synd_test::temp_dir; mod helper; @@ -483,9 +488,74 @@ mod test { Ok(()) } + #[tokio::test(flavor = "multi_thread")] + async fn github_notifications() -> anyhow::Result<()> { + helper::init_tracing(); + + let test_case = TestCase { + mock_port: 6070, + synd_api_port: 6071, + kvsd_port: 6072, + terminal_col_row: (120, 30), + // Enable github notification features + config: Config { + features: Features { + enable_github_notification: true, + }, + ..test_config() + }, + now: Some(Utc::with_ymd_and_hms(&Utc, 2024, 5, 5, 8, 0, 0).unwrap()), + ..Default::default() + } + .already_logined(); + + let mut application = test_case.init_app().await?; + let (tx, mut event_stream) = helper::event_stream(); + + { + application + .wait_until_jobs_completed(&mut event_stream) + .await; + insta::with_settings!({ + description => "github notifications initial", + },{ + insta::assert_debug_snapshot!("gh_notifications_init", application.buffer()); + }); + } + + // TODO + /* + { + // Done + tx.send(key!('d')); + application + .wait_until_jobs_completed(&mut event_stream) + .await; + + // Unsubscribe + tx.send(key!('u')); + application + .wait_until_jobs_completed(&mut event_stream) + .await; + + insta::with_settings!({ + description => "github notifications mark as done", + },{ + insta::assert_debug_snapshot!("gh_notifications_mark_as_done", application.buffer()); + }); + } + */ + + Ok(()) + } + #[tokio::test(flavor = "multi_thread")] async fn cli_commands() -> anyhow::Result<()> { + helper::init_tracing(); let test_case = TestCase { + // If the mock server on port 7000 is not functioning properly on macOS + // it might be due to the AirPlay Receiver. + // try uncheking System Preferences > AirPlay Receiver > On to resolve the issue. mock_port: 7000, synd_api_port: 7001, kvsd_port: 7002, diff --git a/crates/synd_term/tests/snapshots/integration__test__filter_entries_category_filter_entries.snap b/crates/synd_term/tests/snapshots/integration__test__filter_entries_category_filter_entries.snap index 4b0fa049..42bc268e 100644 --- a/crates/synd_term/tests/snapshots/integration__test__filter_entries_category_filter_entries.snap +++ b/crates/synd_term/tests/snapshots/integration__test__filter_entries_category_filter_entries.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MAY 󰭎 a  b (Esc/+/-) ", + " 󰈶 Filter MAY 󰭎 a  b (Esc/+/-) ", "  Search ", " ", " Published Entry 1/30 Feed Req ", @@ -35,7 +35,7 @@ Buffer { " ## Booking.com’s o11y platform ", " ", " Santanu Sahoo shared [Events: The 4th pillar of Booking.com’s Observability platform][1] where he describes ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -47,16 +47,16 @@ Buffer { x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 14, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 17, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 22, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: UNDERLINED, - x: 23, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 24, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, - x: 25, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 26, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, - x: 27, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 28, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, - x: 37, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: UNDERLINED, + x: 22, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 23, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 24, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 25, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 26, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 27, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 36, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch.snap b/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch.snap index 3222d17c..b86e6162 100644 --- a/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch.snap +++ b/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MAY 󰭎  ", + " 󰈶 Filter MAY 󰭎  ", "  Search ", " ", " Published Entry 1/34 Feed Req ", @@ -35,7 +35,7 @@ Buffer { " ## Booking.com’s o11y platform ", " ", " Santanu Sahoo shared [Events: The 4th pillar of Booking.com’s Observability platform][1] where he describes ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -47,10 +47,10 @@ Buffer { x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 14, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 17, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 24, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 25, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 23, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 24, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch_feed.snap b/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch_feed.snap index 90f91f46..121a6e9a 100644 --- a/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch_feed.snap +++ b/crates/synd_term/tests/snapshots/integration__test__filter_entries_initial_fetch_feed.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MAY 󰭎  ", + " 󰈶 Filter MAY 󰭎  ", "  Search ", " ", " Updated Feed 1/2 URL Description Req ", @@ -35,7 +35,7 @@ Buffer { " 2024-05-29 This Week in Rust 549 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-22 This Week in Rust 548 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-15 This Week in Rust 547 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 a:󰑫 e: d:󰼡 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 a:󰑫 e: d:󰼡 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -47,10 +47,10 @@ Buffer { x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 14, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 17, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 24, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 25, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 23, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 24, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__filter_entries_keyword_search_entries.snap b/crates/synd_term/tests/snapshots/integration__test__filter_entries_keyword_search_entries.snap index 1337642d..60577034 100644 --- a/crates/synd_term/tests/snapshots/integration__test__filter_entries_keyword_search_entries.snap +++ b/crates/synd_term/tests/snapshots/integration__test__filter_entries_keyword_search_entries.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MAY 󰭎  ", + " 󰈶 Filter MAY 󰭎  ", "  Search rust 549 ", " ", " Published Entry 1/1 Feed Req ", @@ -35,7 +35,7 @@ Buffer { " Hello and welcome to another issue of *This Week in Rust*! [Rust][1] is a programming language empowering everyone ", " to build reliable and efficient software. This is a weekly summary of its progress and community. Want something ", " mentioned? Tag us at [@ThisWeekInRust][2] on X(formerly Twitter) or [@ThisWeekinRust][3] on mastodon.social … ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -47,10 +47,10 @@ Buffer { x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 14, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 17, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 24, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 25, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 23, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 24, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 22, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: REVERSED, x: 23, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__filter_entries_req_must_entries.snap b/crates/synd_term/tests/snapshots/integration__test__filter_entries_req_must_entries.snap index f18d8d42..ffc479b1 100644 --- a/crates/synd_term/tests/snapshots/integration__test__filter_entries_req_must_entries.snap +++ b/crates/synd_term/tests/snapshots/integration__test__filter_entries_req_must_entries.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MST 󰭎  ", + " 󰈶 Filter MST 󰭎  ", "  Search ", " ", " Published Entry 1/4 Feed Req ", @@ -35,7 +35,7 @@ Buffer { " Hello and welcome to another issue of *This Week in Rust*! [Rust][1] is a programming language empowering everyone ", " to build reliable and efficient software. This is a weekly summary of its progress and community. Want something ", " mentioned? Tag us at [@ThisWeekInRust][2] on X(formerly Twitter) or [@ThisWeekinRust][3] on mastodon.social … ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -45,10 +45,10 @@ Buffer { x: 108, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 24, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 25, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: White, bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 23, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 24, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__gh_notifications_init.snap b/crates/synd_term/tests/snapshots/integration__test__gh_notifications_init.snap new file mode 100644 index 00000000..1494f770 --- /dev/null +++ b/crates/synd_term/tests/snapshots/integration__test__gh_notifications_init.snap @@ -0,0 +1,67 @@ +--- +source: crates/synd_term/tests/integration.rs +description: github notifications initial +expression: application.buffer() +--- +Buffer { + area: Rect { x: 0, y: 0, width: 120, height: 30 }, + content: [ + " Syndicationd 󰊤 GitHub 󱉯 Entries 󰑫 Feeds ", + " 󰈶 Filter Unread Participating ", + "  Search ", + " ", + " Updated Title 1/2 Reposi Reason ", + " 1d ago  title AA1 repo-1 notyet ▐", + " 1d ago  title AA2 repo-1 notyet ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + "  Subject org-1 / repo-1 # ", + " 󰯂 Title title AA1 ", + "  UpdatedAt 2024-05-04 08:00 (+00:00) ", + " 󱙓 PR ", + " ", + " ", + " ", + " ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: f:󰈶 Ent:󰏌 d: u: r:󰑓 q: ", + ], + styles: [ + x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 14, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 87, y: 0, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 95, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 33, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, + x: 0, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 119, y: 5, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 0, y: 6, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 21, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 11, y: 21, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 22, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 9, y: 22, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 23, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 13, y: 23, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 24, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, + x: 6, y: 24, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 26, y: 29, fg: Rgb(111, 93, 99), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 94, y: 29, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + ] +} diff --git a/crates/synd_term/tests/snapshots/integration__test__gh_notifications_mark_as_done.snap.new b/crates/synd_term/tests/snapshots/integration__test__gh_notifications_mark_as_done.snap.new new file mode 100644 index 00000000..76ac357f --- /dev/null +++ b/crates/synd_term/tests/snapshots/integration__test__gh_notifications_mark_as_done.snap.new @@ -0,0 +1,75 @@ +--- +source: crates/synd_term/tests/integration.rs +assertion_line: 542 +description: github notifications mark as done +expression: application.buffer() +--- +Buffer { + area: Rect { x: 0, y: 0, width: 120, height: 30 }, + content: [ + " Syndicationd 󰊤 GitHub 󱉯 Entries 󰑫 Feeds ", + " 󰈶 Filter Unread Participating ", + "  Search ", + " ", + " Updated Title 1/2 Reposi Reason ", + " 1d ago  title AA1 repo-1 notyet ▐", + " 1d ago  title AA2 repo-1 notyet ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + "  Subject org-1 / repo-1 # ", + " 󰯂 Title title AA1 ", + "  UpdatedAt 2024-05-04 08:00 (+00:00) ", + " 󱙓 PR ", + " ", + " ", + " ", + " ", + "Serde Error: EOF while parsing a value at line 1 column 0Found at 0: std::backtrace_rs::backtrace::libunwind::trace ", + ], + styles: [ + x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 14, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 87, y: 0, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 95, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 33, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, + x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, + x: 0, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 2, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | DIM | CROSSED_OUT, + x: 9, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 12, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | DIM | CROSSED_OUT, + x: 100, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 102, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | DIM | CROSSED_OUT, + x: 108, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 110, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | DIM | CROSSED_OUT, + x: 116, y: 5, fg: Rgb(255, 160, 122), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 119, y: 5, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 0, y: 6, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 21, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 11, y: 21, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 22, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 9, y: 22, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 23, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD, + x: 13, y: 23, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 2, y: 24, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, + x: 6, y: 24, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 0, y: 29, fg: Rgb(224, 107, 117), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + ] +} diff --git a/crates/synd_term/tests/snapshots/integration__test__google_landing_entries.snap b/crates/synd_term/tests/snapshots/integration__test__google_landing_entries.snap index 9ddb0402..ad7b85d6 100644 --- a/crates/synd_term/tests/snapshots/integration__test__google_landing_entries.snap +++ b/crates/synd_term/tests/snapshots/integration__test__google_landing_entries.snap @@ -35,7 +35,7 @@ Buffer { " ", " ", " ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, diff --git a/crates/synd_term/tests/snapshots/integration__test__landing_entries.snap b/crates/synd_term/tests/snapshots/integration__test__landing_entries.snap index 76a33c9a..27af4809 100644 --- a/crates/synd_term/tests/snapshots/integration__test__landing_entries.snap +++ b/crates/synd_term/tests/snapshots/integration__test__landing_entries.snap @@ -35,7 +35,7 @@ Buffer { " ", " ", " ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, diff --git a/crates/synd_term/tests/snapshots/integration__test__refresh_expired_google_jwt_landing.snap b/crates/synd_term/tests/snapshots/integration__test__refresh_expired_google_jwt_landing.snap index 9ab6a889..1b2f4de9 100644 --- a/crates/synd_term/tests/snapshots/integration__test__refresh_expired_google_jwt_landing.snap +++ b/crates/synd_term/tests/snapshots/integration__test__refresh_expired_google_jwt_landing.snap @@ -35,7 +35,7 @@ Buffer { " ", " ", " ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, diff --git a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_edit.snap b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_edit.snap index 6d05aba4..95841228 100644 --- a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_edit.snap +++ b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_edit.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MAY  ", + " 󰈶 Filter MAY  ", "  Search ", " ", " Updated Feed 1/1 URL Description Req ", @@ -35,7 +35,7 @@ Buffer { " 2024-05-29 This Week in Rust 549 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-22 This Week in Rust 548 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-15 This Week in Rust 547 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 a:󰑫 e: d:󰼡 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 a:󰑫 e: d:󰼡 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -47,8 +47,8 @@ Buffer { x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 14, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 17, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_editor_parse.snap b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_editor_parse.snap index 662eec46..376b5138 100644 --- a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_editor_parse.snap +++ b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_after_editor_parse.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MAY  ", + " 󰈶 Filter MAY  ", "  Search ", " ", " Updated Feed 1/1 URL Description Req ", @@ -35,7 +35,7 @@ Buffer { " 2024-05-29 This Week in Rust 549 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-22 This Week in Rust 548 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-15 This Week in Rust 547 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 a:󰑫 e: d:󰼡 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 a:󰑫 e: d:󰼡 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -47,8 +47,8 @@ Buffer { x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 14, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 17, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_landing_feeds.snap b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_landing_feeds.snap index b012ce2b..48b5d232 100644 --- a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_landing_feeds.snap +++ b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_landing_feeds.snap @@ -35,7 +35,7 @@ Buffer { " ", " ", " ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 a:󰑫 e: d:󰼡 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 a:󰑫 e: d:󰼡 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, diff --git a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribe_popup.snap b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribe_popup.snap index 69d73c0e..08e9852b 100644 --- a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribe_popup.snap +++ b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribe_popup.snap @@ -7,7 +7,7 @@ Buffer { area: Rect { x: 0, y: 0, width: 120, height: 30 }, content: [ " Syndicationd 󱉯 Entries 󰑫 Feeds ", - " 󰈶 Filter MAY  ", + " 󰈶 Filter MAY  ", "  Search ", " ", " Updated Feed 1/1 URL Description Req ", @@ -35,7 +35,7 @@ Buffer { " 2024-05-29 This Week in Rust 549 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-22 This Week in Rust 548 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", " 2024-05-15 This Week in Rust 547 Hello and welcome to another issue of *This Week in Rust*! [Rust][1] ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 a:󰑫 e: d:󰼡 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 a:󰑫 e: d:󰼡 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, @@ -47,8 +47,8 @@ Buffer { x: 10, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 14, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 17, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 20, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, - x: 21, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 19, y: 1, fg: Rgb(247, 76, 0), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, + x: 20, y: 1, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 2, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: DIM, x: 10, y: 2, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, x: 0, y: 4, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: BOLD | UNDERLINED, diff --git a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribed.snap b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribed.snap index 7fefabf1..109023f5 100644 --- a/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribed.snap +++ b/crates/synd_term/tests/snapshots/integration__test__subscribe_then_unsubscribe_unsubscribed.snap @@ -35,7 +35,7 @@ Buffer { " ", " ", " ", - " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 h/l: c: /: r:󰑓 Ent:󰏌 a:󰑫 e: d:󰼡 q: ", + " Tab:󰹳 j/k:󰹹 gg:󱞧 ge:󱞥 c: /: h/l: Ent:󰏌 a:󰑫 e: d:󰼡 r:󰑓 q: ", ], styles: [ x: 0, y: 0, fg: Rgb(254, 205, 178), bg: Rgb(43, 41, 45), underline: Reset, modifier: NONE, diff --git a/crates/synd_term/tests/test/helper.rs b/crates/synd_term/tests/test/helper.rs index f1289c83..e20ad6a0 100644 --- a/crates/synd_term/tests/test/helper.rs +++ b/crates/synd_term/tests/test/helper.rs @@ -1,6 +1,8 @@ use std::{io, path::PathBuf, sync::Once, time::Duration}; +use chrono::{DateTime, Utc}; use futures_util::future; +use octocrab::Octocrab; use ratatui::backend::TestBackend; use synd_api::{ args::{CacheOptions, KvsdOptions, ServeOptions, TlsOptions}, @@ -14,12 +16,15 @@ use synd_auth::{ jwt, }; use synd_term::{ - application::{Application, Authenticator, Cache, Config, DeviceFlows, JwtService}, + application::{ + Application, Authenticator, Cache, Clock, Config, DeviceFlows, JwtService, SystemClock, + }, auth::Credential, - client::Client, + client::{github::GithubClient as TermGithubClient, Client}, config::Categories, interact::Interactor, terminal::Terminal, + types::Time, ui::theme::Theme, }; use synd_test::temp_dir; @@ -29,6 +34,14 @@ use tokio_util::sync::CancellationToken; use tracing_subscriber::EnvFilter; use url::Url; +struct DummyClock(Time); + +impl Clock for DummyClock { + fn now(&self) -> DateTime { + self.0 + } +} + #[derive(Clone)] pub struct TestCase { pub mock_port: u16, @@ -40,6 +53,7 @@ pub struct TestCase { pub device_flow_case: &'static str, pub cache_dir: PathBuf, pub log_path: PathBuf, + pub now: Option