diff --git a/.config/config.json5 b/.config/config.json5 index 805691d..b8f5d45 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -7,6 +7,10 @@ "": "Suspend", // Suspend the application "j": "Down", // Move down "k": "Up", // Move up + "down": "Down", // Move down + "up": "Up", // Move up + "r": "GetRepos", // Refresh the list of repositories + "enter": "Enter" }, } } diff --git a/Cargo.lock b/Cargo.lock index 2fc42e4..a282f64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,8 +308,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.5", ] @@ -836,6 +838,7 @@ name = "ghtui-rs" version = "0.1.0" dependencies = [ "better-panic", + "chrono", "clap", "color-eyre", "config", @@ -850,6 +853,7 @@ dependencies = [ "libc", "log", "octocrab", + "open", "pretty_assertions", "ratatui", "serde", @@ -1664,6 +1668,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.12.1" @@ -1952,6 +1975,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "open" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449f0ff855d85ddbf1edd5b646d65249ead3f5e422aaa86b7d2d0b049b103e32" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl-probe" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 609598a..dcd6bab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ build = "build.rs" [dependencies] better-panic = "0.3.0" +chrono = "0.4.38" clap = { version = "4.4.5", features = [ "derive", "cargo", @@ -32,6 +33,7 @@ lazy_static = "1.4.0" libc = "0.2.148" log = "0.4.20" octocrab = "0.38.0" +open = "5.1.2" pretty_assertions = "1.4.0" ratatui = { version = "0.26.0", features = ["serde", "macros"] } serde = { version = "1.0.188", features = ["derive"] } diff --git a/src/action.rs b/src/action.rs index 1fe6a56..8d31103 100644 --- a/src/action.rs +++ b/src/action.rs @@ -6,6 +6,8 @@ use serde::{ }; use strum::Display; +use crate::components::pull_request::PullRequest; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Display, Deserialize)] pub enum Action { Tick, @@ -21,4 +23,7 @@ pub enum Action { // custom actions Up, Down, + GetReposResult(Vec), + GetRepos, + Enter, } diff --git a/src/app.rs b/src/app.rs index 42c4efd..3f98453 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc; use crate::{ action::Action, - components::{fps::FpsCounter, home::Home, keystrokes::Keystrokes, repo_list::RepoList, Component}, + components::{fps::FpsCounter, home::Home, keystrokes::Keystrokes, repo_list::PullRequestList, Component}, config::Config, mode::Mode, tui, @@ -26,14 +26,14 @@ pub struct App { impl App { pub fn new(tick_rate: f64, frame_rate: f64) -> Result { let home = Home::new(); + let pr_list = PullRequestList::default(); let keystrokes = Keystrokes::default(); - let file_list = RepoList::default(); let config = Config::new()?; let mode = Mode::Normal; Ok(Self { tick_rate, frame_rate, - components: vec![Box::new(home), Box::new(keystrokes), Box::new(file_list)], + components: vec![Box::new(home), Box::new(keystrokes), Box::new(pr_list)], should_quit: false, should_suspend: false, config, diff --git a/src/components.rs b/src/components.rs index 2283f90..e5109b6 100644 --- a/src/components.rs +++ b/src/components.rs @@ -12,6 +12,7 @@ use crate::{ pub mod fps; pub mod home; pub mod keystrokes; +pub mod pull_request; pub mod repo_list; /// `Component` is a trait that represents a visual and interactive element of the user interface. diff --git a/src/components/pull_request.rs b/src/components/pull_request.rs new file mode 100644 index 0000000..705a590 --- /dev/null +++ b/src/components/pull_request.rs @@ -0,0 +1,44 @@ +type URI = String; +type DateTime = chrono::DateTime; + +use graphql_client::GraphQLQuery; +use serde::{Deserialize, Serialize}; + +use self::pull_requests_query::{PullRequestsQuerySearchEdgesNode, PullRequestsQuerySearchEdgesNodeOnPullRequest}; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/github/schema.graphql", + query_path = "src/github/queries/pull_requests.graphql", + variables_derives = "Clone, Debug", + response_derives = "Clone, Debug" +)] +pub struct PullRequestsQuery; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequest { + pub number: usize, + pub title: String, + pub repository: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub url: URI, + pub changed_files: usize, + pub additions: usize, + pub deletions: usize, +} + +impl From<&PullRequestsQuerySearchEdgesNodeOnPullRequest> for PullRequest { + fn from(value: &PullRequestsQuerySearchEdgesNodeOnPullRequest) -> Self { + Self { + number: value.number as usize, + title: value.title.clone(), + repository: value.repository.name_with_owner.clone(), + created_at: value.created_at, + updated_at: value.updated_at, + url: value.url.clone(), + changed_files: value.changed_files as usize, + additions: value.additions as usize, + deletions: value.deletions as usize, + } + } +} diff --git a/src/components/repo_list.rs b/src/components/repo_list.rs index 0d737fd..08b5f06 100644 --- a/src/components/repo_list.rs +++ b/src/components/repo_list.rs @@ -1,39 +1,75 @@ use std::{collections::HashMap, time::Duration}; -use color_eyre::eyre::Result; +use color_eyre::{eyre::Result, owo_colors::OwoColorize}; use crossterm::event::{KeyCode, KeyEvent}; +use graphql_client::GraphQLQuery; use octocrab::Octocrab; -use ratatui::{prelude::*, widgets::*}; +use ratatui::{ + prelude::*, + widgets::{ + block::{Position, Title}, + *, + }, +}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::UnboundedSender; -use super::{Component, Frame}; +use super::pull_request; use crate::{ action::Action, + components::{ + pull_request::{pull_requests_query, PullRequest, PullRequestsQuery}, + Component, Frame, + }, config::{Config, KeyBindings}, }; #[derive(Default)] -pub struct RepoList { +pub struct PullRequestList { command_tx: Option>, config: Config, selected_row: usize, + pull_requests: Vec, } -impl RepoList { +impl PullRequestList { pub fn new() -> Self { Self::default() } fn fetch_repos(&mut self) -> Result<()> { - let token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN must be set"); - let oc = Octocrab::builder().personal_token(token).build().expect("Failed to create Octocrab client"); - let repos = oc.graphql({}).await?; + let tx = self.command_tx.clone().unwrap(); + tokio::spawn(async move { + let token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN must be set"); + let oc = Octocrab::builder().personal_token(token).build().expect("Failed to create Octocrab client"); + let response: octocrab::Result> = + oc.graphql(&PullRequestsQuery::build_query(pull_requests_query::Variables {})).await; + match response { + Ok(response) => { + let r = response.data.unwrap().search.edges.unwrap(); + let pull_requests: Vec = r + .iter() + .map(|v: &Option| { + let inner = v.as_ref().unwrap().node.as_ref().unwrap(); + match inner { + pull_requests_query::PullRequestsQuerySearchEdgesNode::PullRequest(pr) => pr.into(), + _ => panic!("Unexpected node type: {:?}", inner), + } + }) + .collect(); + pull_requests.iter().for_each(|pr| {}); + tx.send(Action::GetReposResult(pull_requests)).unwrap(); + }, + Err(e) => { + tx.send(Action::Error(e.to_string())).unwrap(); + }, + } + }); Ok(()) } } -impl Component for RepoList { +impl Component for PullRequestList { fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.command_tx = Some(tx); Ok(()) @@ -52,25 +88,56 @@ impl Component for RepoList { return Ok(Some(Action::Render)); }, Action::Down => { - self.selected_row = self.selected_row.saturating_add(1); + self.selected_row = std::cmp::min(self.selected_row + 1, self.pull_requests.len() - 1); return Ok(Some(Action::Render)); }, + Action::GetRepos => { + self.fetch_repos()?; + }, + Action::GetReposResult(pull_requests) => { + self.pull_requests = pull_requests; + }, + Action::Enter => { + let pr = self.pull_requests.get(self.selected_row).unwrap(); + let url = pr.url.clone(); + let _ = open::that(url); + }, _ => {}, } Ok(None) } fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { - let rows = vec![Row::new(vec!["Cell11", "Cell12"]); 10]; + let rows = self + .pull_requests + .iter() + .map(|pr: &PullRequest| { + Row::new(vec![ + Cell::from(format!("{:}", pr.number)), + Cell::from(pr.repository.clone()), + Cell::from(pr.title.clone()), + Cell::from(format!("{}", pr.created_at.format("%Y-%m-%d %H:%M"))), + Cell::from(format!("{}", pr.updated_at.format("%Y-%m-%d %H:%M"))), + Cell::from(Line::from(vec![ + Span::styled(format!("{:+}", pr.additions), Style::new().fg(Color::LightGreen)), + Span::styled(format!("{:+}", (0 - pr.deletions as isize)), Style::new().fg(Color::LightRed)), + ])), + ]) + }) + .collect::>(); let mut table_state = TableState::default(); table_state.select(Some(self.selected_row)); let table = Table::default() - .widths(Constraint::from_lengths([5, 5])) + .widths(Constraint::from_lengths([4, 30, 60, 20, 20, 6, 6])) .rows(rows) .column_spacing(1) - .header(Row::new(vec!["Col1", "Col2"]).bottom_margin(1)) - .footer(Row::new(vec!["Footer1", "Footer2"])) - .block(Block::default().title("Title")) + .header(Row::new(vec!["#", "Title", "Repository", "Created", "Updated"]).bottom_margin(1)) + .block( + Block::default() + .title(Title::from("Pull Requests")) + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ) .highlight_style(Style::new().reversed().add_modifier(Modifier::BOLD)) .highlight_symbol(">> "); @@ -78,20 +145,19 @@ impl Component for RepoList { Ok(()) } } - #[cfg(test)] mod tests { use super::*; #[test] fn test_new() { - let item_list = RepoList::new(); + let item_list = PullRequestList::new(); assert_eq!(item_list.selected_row, 0); } #[test] fn test_up_down_actions() { - let mut item_list = RepoList::new(); + let mut item_list = PullRequestList::new(); assert_eq!(item_list.update(Action::Up).unwrap(), Some(Action::Render)); assert_eq!(item_list.update(Action::Down).unwrap(), Some(Action::Render)); } diff --git a/src/github/queries/pull_requests.graphql b/src/github/queries/pull_requests.graphql new file mode 100644 index 0000000..880f9a7 --- /dev/null +++ b/src/github/queries/pull_requests.graphql @@ -0,0 +1,39 @@ +query PullRequestsQuery { + search($query: String!, type: ISSUE, $first: Int!) { + edges { + node { + __typename + ... on PullRequest { + number + title + repository { + nameWithOwner + } + createdAt + updatedAt + url + changedFiles + additions + deletions + latestReviews(last: 10) { + edges { + node { + author { + __typename + login + } + } + } + } + participants(last: 10) { + edges { + node { + login + } + } + } + } + } + } + } +} diff --git a/src/list b/src/list deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.rs b/src/main.rs index cd3303b..5bcfb06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,28 +16,28 @@ use cli::Cli; use color_eyre::eyre::Result; use crate::{ - app::App, - utils::{initialize_logging, initialize_panic_handler, version}, + app::App, + utils::{initialize_logging, initialize_panic_handler, version}, }; async fn tokio_main() -> Result<()> { - initialize_logging()?; + initialize_logging()?; - initialize_panic_handler()?; + initialize_panic_handler()?; - let args = Cli::parse(); - let mut app = App::new(args.tick_rate, args.frame_rate)?; - app.run().await?; + let args = Cli::parse(); + let mut app = App::new(args.tick_rate, args.frame_rate)?; + app.run().await?; - Ok(()) + Ok(()) } #[tokio::main] async fn main() -> Result<()> { - if let Err(e) = tokio_main().await { - eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME")); - Err(e) - } else { - Ok(()) - } + if let Err(e) = tokio_main().await { + eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME")); + Err(e) + } else { + Ok(()) + } } diff --git a/src/utils.rs b/src/utils.rs index 938e470..1d959ab 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -8,112 +8,112 @@ use tracing_error::ErrorLayer; use tracing_subscriber::{self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, Layer}; const VERSION_MESSAGE: &str = - concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_DESCRIBE"), " (", env!("VERGEN_BUILD_DATE"), ")"); + concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_DESCRIBE"), " (", env!("VERGEN_BUILD_DATE"), ")"); lazy_static! { - pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); - pub static ref DATA_FOLDER: Option = - std::env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from); - pub static ref CONFIG_FOLDER: Option = - std::env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from); - pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone()); - pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); + pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); + pub static ref DATA_FOLDER: Option = + std::env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from); + pub static ref CONFIG_FOLDER: Option = + std::env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from); + pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone()); + pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); } fn project_directory() -> Option { - ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) + ProjectDirs::from("com", "ghtui-rs", env!("CARGO_PKG_NAME")) } pub fn initialize_panic_handler() -> Result<()> { - let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() - .panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY"))) - .capture_span_trace_by_default(false) - .display_location_section(false) - .display_env_section(false) - .into_hooks(); - eyre_hook.install()?; - std::panic::set_hook(Box::new(move |panic_info| { - if let Ok(mut t) = crate::tui::Tui::new() { - if let Err(r) = t.exit() { - error!("Unable to exit Terminal: {:?}", r); - } - } - - #[cfg(not(debug_assertions))] - { - use human_panic::{handle_dump, print_msg, Metadata}; - let meta = Metadata { - version: env!("CARGO_PKG_VERSION").into(), - name: env!("CARGO_PKG_NAME").into(), - authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(), - homepage: env!("CARGO_PKG_HOMEPAGE").into(), - }; - - let file_path = handle_dump(&meta, panic_info); - // prints human-panic message - print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); - eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr - } - let msg = format!("{}", panic_hook.panic_report(panic_info)); - log::error!("Error: {}", strip_ansi_escapes::strip_str(msg)); - - #[cfg(debug_assertions)] - { - // Better Panic stacktrace that is only enabled when debugging. - better_panic::Settings::auto() - .most_recent_first(false) - .lineno_suffix(true) - .verbosity(better_panic::Verbosity::Full) - .create_panic_handler()(panic_info); - } - - std::process::exit(libc::EXIT_FAILURE); - })); - Ok(()) + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY"))) + .capture_span_trace_by_default(false) + .display_location_section(false) + .display_env_section(false) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Ok(mut t) = crate::tui::Tui::new() { + if let Err(r) = t.exit() { + error!("Unable to exit Terminal: {:?}", r); + } + } + + #[cfg(not(debug_assertions))] + { + use human_panic::{handle_dump, print_msg, Metadata}; + let meta = Metadata { + version: env!("CARGO_PKG_VERSION").into(), + name: env!("CARGO_PKG_NAME").into(), + authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(), + homepage: env!("CARGO_PKG_HOMEPAGE").into(), + }; + + let file_path = handle_dump(&meta, panic_info); + // prints human-panic message + print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); + eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr + } + let msg = format!("{}", panic_hook.panic_report(panic_info)); + log::error!("Error: {}", strip_ansi_escapes::strip_str(msg)); + + #[cfg(debug_assertions)] + { + // Better Panic stacktrace that is only enabled when debugging. + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(libc::EXIT_FAILURE); + })); + Ok(()) } pub fn get_data_dir() -> PathBuf { - let directory = if let Some(s) = DATA_FOLDER.clone() { - s - } else if let Some(proj_dirs) = project_directory() { - proj_dirs.data_local_dir().to_path_buf() - } else { - PathBuf::from(".").join(".data") - }; - directory + let directory = if let Some(s) = DATA_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.data_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".data") + }; + directory } pub fn get_config_dir() -> PathBuf { - let directory = if let Some(s) = CONFIG_FOLDER.clone() { - s - } else if let Some(proj_dirs) = project_directory() { - proj_dirs.config_local_dir().to_path_buf() - } else { - PathBuf::from(".").join(".config") - }; - directory + let directory = if let Some(s) = CONFIG_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.config_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".config") + }; + directory } pub fn initialize_logging() -> Result<()> { - let directory = get_data_dir(); - std::fs::create_dir_all(directory.clone())?; - let log_path = directory.join(LOG_FILE.clone()); - let log_file = std::fs::File::create(log_path)?; - std::env::set_var( - "RUST_LOG", - std::env::var("RUST_LOG") - .or_else(|_| std::env::var(LOG_ENV.clone())) - .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))), - ); - let file_subscriber = tracing_subscriber::fmt::layer() - .with_file(true) - .with_line_number(true) - .with_writer(log_file) - .with_target(false) - .with_ansi(false) - .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env()); - tracing_subscriber::registry().with(file_subscriber).with(ErrorLayer::default()).init(); - Ok(()) + let directory = get_data_dir(); + std::fs::create_dir_all(directory.clone())?; + let log_path = directory.join(LOG_FILE.clone()); + let log_file = std::fs::File::create(log_path)?; + std::env::set_var( + "RUST_LOG", + std::env::var("RUST_LOG") + .or_else(|_| std::env::var(LOG_ENV.clone())) + .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))), + ); + let file_subscriber = tracing_subscriber::fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false) + .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env()); + tracing_subscriber::registry().with(file_subscriber).with(ErrorLayer::default()).init(); + Ok(()) } /// Similar to the `std::dbg!` macro, but generates `tracing` events rather @@ -143,19 +143,19 @@ macro_rules! trace_dbg { } pub fn version() -> String { - let author = clap::crate_authors!(); + let author = clap::crate_authors!(); - // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); - let config_dir_path = get_config_dir().display().to_string(); - let data_dir_path = get_data_dir().display().to_string(); + // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); + let config_dir_path = get_config_dir().display().to_string(); + let data_dir_path = get_data_dir().display().to_string(); - format!( - "\ + format!( + "\ {VERSION_MESSAGE} Authors: {author} Config directory: {config_dir_path} Data directory: {data_dir_path}" - ) + ) }