diff --git a/Cargo.lock b/Cargo.lock index 7a8a819..42f7ac5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,6 +315,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix", @@ -570,8 +571,10 @@ dependencies = [ "async-trait", "bytes", "clap", + "crossterm", "dirs", "dotenv", + "futures", "gitea-rs", "nucleo-matcher", "octocrab", diff --git a/crates/gitnow/Cargo.toml b/crates/gitnow/Cargo.toml index 94487d7..365239e 100644 --- a/crates/gitnow/Cargo.toml +++ b/crates/gitnow/Cargo.toml @@ -26,6 +26,8 @@ prost-types = "0.13.2" bytes = "1.7.1" nucleo-matcher = "0.3.1" ratatui = "0.28.1" +crossterm = { version = "0.28.0", features = ["event-stream"] } +futures = "0.3.30" [dev-dependencies] pretty_assertions = "1.4.0" diff --git a/crates/gitnow/src/commands/root.rs b/crates/gitnow/src/commands/root.rs index 85e3a0c..4b2ae50 100644 --- a/crates/gitnow/src/commands/root.rs +++ b/crates/gitnow/src/commands/root.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use crate::{ app::App, cache::CacheApp, + components::inline_command::InlineCommand, fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp}, git_clone::GitCloneApp, git_provider::Repository, @@ -75,9 +76,17 @@ impl RootCommand { }; if clone { - self.app - .git_clone() - .clone_repo(&repo, force_refresh) + let git_clone = self.app.git_clone(); + + let mut wrap_cmd = + InlineCommand::new(format!("cloning: {}", repo.to_rel_path().display())); + let repo = repo.clone(); + wrap_cmd + .execute(move || async move { + git_clone.clone_repo(&repo, force_refresh).await?; + + Ok(()) + }) .await?; } else { tracing::info!("skipping clone for repo: {}", &repo.to_rel_path().display()); diff --git a/crates/gitnow/src/components.rs b/crates/gitnow/src/components.rs new file mode 100644 index 0000000..3c75181 --- /dev/null +++ b/crates/gitnow/src/components.rs @@ -0,0 +1,120 @@ +use anyhow::Error; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +pub mod inline_command; +pub mod spinner; + +#[derive(Debug, PartialEq)] +pub enum Msg { + Quit, + Tick, + Success, + Failure(String), +} + +pub struct Command { + func: Box, +} + +impl Command { + pub fn new Option + 'static>(f: T) -> Self { + Self { func: Box::new(f) } + } + + pub fn execute(self, dispatch: &Dispatch) -> Option { + (self.func)(dispatch) + } +} + +pub trait IntoCommand { + fn into_command(self) -> Command; +} + +impl IntoCommand for () { + fn into_command(self) -> Command { + Command::new(|_| None) + } +} + +impl IntoCommand for Command { + fn into_command(self) -> Command { + self + } +} + +impl IntoCommand for Msg { + fn into_command(self) -> Command { + Command::new(|_| Some(self)) + } +} + +type CommandFunc = dyn FnOnce(&Dispatch) -> Option; + +pub fn create_dispatch() -> (Dispatch, Receiver) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + + (Dispatch { sender: tx }, Receiver { receiver: rx }) +} + +#[derive(Clone)] +pub struct Dispatch { + sender: UnboundedSender, +} + +impl Dispatch { + pub fn send(&self, msg: Msg) { + if let Err(e) = self.sender.send(msg) { + tracing::warn!("failed to send event: {}", e); + } + } +} + +pub struct Receiver { + receiver: UnboundedReceiver, +} + +impl Receiver { + pub async fn next(&mut self) -> Option { + self.receiver.recv().await + } +} + +#[derive(Default)] +pub struct BatchCommand { + commands: Vec, +} + +impl BatchCommand { + pub fn with(&mut self, cmd: impl IntoCommand) -> &mut Self { + self.commands.push(cmd.into_command()); + + self + } +} + +impl IntoCommand for Vec { + fn into_command(self) -> Command { + BatchCommand::from(self).into_command() + } +} + +impl From> for BatchCommand { + fn from(value: Vec) -> Self { + BatchCommand { commands: value } + } +} + +impl IntoCommand for BatchCommand { + fn into_command(self) -> Command { + Command::new(|dispatch| { + for command in self.commands { + let msg = command.execute(dispatch); + if let Some(msg) = msg { + dispatch.send(msg); + } + } + + None + }) + } +} diff --git a/crates/gitnow/src/components/inline_command.rs b/crates/gitnow/src/components/inline_command.rs new file mode 100644 index 0000000..672e9ed --- /dev/null +++ b/crates/gitnow/src/components/inline_command.rs @@ -0,0 +1,194 @@ +use std::{io::Write, time::Duration}; + +use anyhow::Context; +use crossterm::event::{EventStream, KeyCode, KeyEventKind}; +use futures::{FutureExt, StreamExt}; +use ratatui::{ + crossterm, + prelude::*, + widgets::{Block, Padding, Paragraph}, + TerminalOptions, Viewport, +}; + +use crate::components::{BatchCommand, Command}; + +use super::{ + create_dispatch, + spinner::{Spinner, SpinnerState}, + Dispatch, IntoCommand, Msg, Receiver, +}; + +pub struct InlineCommand { + spinner: SpinnerState, + heading: String, +} + +impl InlineCommand { + pub fn new(heading: impl Into) -> Self { + Self { + spinner: SpinnerState::default(), + heading: heading.into(), + } + } + + pub async fn execute(&mut self, func: F) -> anyhow::Result<()> + where + F: FnOnce() -> Fut + Send + Sync + 'static, + Fut: futures::Future> + Send + 'static, + { + tracing::trace!("starting inline terminal"); + + let mut terminal = ratatui::init_with_options(TerminalOptions { + viewport: Viewport::Inline(3), + }); + + let (dispatch, mut receiver) = create_dispatch(); + let mut event_stream = crossterm::event::EventStream::new(); + let guard = TerminalGuard; + + tokio::spawn({ + let dispatch = dispatch.clone(); + + async move { + match func().await { + Ok(_) => dispatch.send(Msg::Success), + Err(e) => dispatch.send(Msg::Failure(e.to_string())), + } + } + }); + + loop { + if self + .update(&mut terminal, &dispatch, &mut receiver, &mut event_stream) + .await? + { + terminal.draw(|f| { + let buf = f.buffer_mut(); + buf.reset(); + })?; + + break; + } + } + + drop(guard); + + Ok(()) + } + + async fn update( + &mut self, + terminal: &mut ratatui::Terminal, + dispatch: &Dispatch, + receiver: &mut Receiver, + event_stream: &mut EventStream, + ) -> anyhow::Result { + let input_event = event_stream.next().fuse(); + let next_msg = receiver.next().fuse(); + + const FRAMES_PER_SECOND: f32 = 60.0; + const TICK_RATE: f32 = 20.0; + + let period_frame = Duration::from_secs_f32(1.0 / FRAMES_PER_SECOND); + let mut interval_frames = tokio::time::interval(period_frame); + let period_tick = Duration::from_secs_f32(1.0 / TICK_RATE); + let mut interval_ticks = tokio::time::interval(period_tick); + + let msg = tokio::select! { + _ = interval_frames.tick() => { + terminal.draw(|frame| self.draw(frame))?; + None + } + _ = interval_ticks.tick() => { + Some(Msg::Tick) + } + msg = next_msg => { + msg + } + input = input_event => { + if let Some(Ok(input)) = input { + self.handle_key_event(input) + } else { + None + } + } + }; + + if let Some(msg) = msg { + if Msg::Quit == msg { + return Ok(true); + } + + let mut cmd = self.update_state(&msg); + + loop { + let msg = cmd.into_command().execute(dispatch); + + match msg { + Some(Msg::Quit) => return Ok(true), + Some(msg) => { + cmd = self.update_state(&msg); + } + None => break, + } + } + } + + Ok(false) + } + + fn draw(&mut self, frame: &mut Frame<'_>) { + let spinner = Spinner::new(Span::from(&self.heading)); + + let block = Block::new().padding(Padding::symmetric(2, 1)); + + StatefulWidget::render( + spinner.block(block), + frame.area(), + frame.buffer_mut(), + &mut self.spinner, + ); + } + + fn handle_key_event(&mut self, event: crossterm::event::Event) -> Option { + if let crossterm::event::Event::Key(key) = event { + return match key.code { + KeyCode::Esc => Some(Msg::Quit), + KeyCode::Char('c') => Some(Msg::Quit), + _ => None, + }; + } + + None + } + + fn update_state(&mut self, msg: &Msg) -> impl IntoCommand { + tracing::debug!("handling message: {:?}", msg); + + let mut batch = BatchCommand::default(); + + match msg { + Msg::Quit => {} + Msg::Tick => {} + Msg::Success => return Msg::Quit.into_command(), + Msg::Failure(f) => { + tracing::error!("command failed: {}", f); + return Msg::Quit.into_command(); + } + } + + batch.with(self.spinner.update(msg)); + + batch.into_command() + } +} + +#[derive(Default)] +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + tracing::trace!("restoring inline terminal"); + ratatui::restore(); + } +} diff --git a/crates/gitnow/src/components/spinner.rs b/crates/gitnow/src/components/spinner.rs new file mode 100644 index 0000000..48d577c --- /dev/null +++ b/crates/gitnow/src/components/spinner.rs @@ -0,0 +1,92 @@ +use std::time::{Duration, Instant}; + +use ratatui::{ + text::{Line, Span, Text}, + widgets::{Block, Paragraph, StatefulWidget, Widget}, +}; + +use super::{BatchCommand, Command, IntoCommand, Msg}; + +pub struct Spinner<'a> { + span: Span<'a>, + block: Option>, +} + +impl<'a> Spinner<'a> { + pub fn new(span: Span<'a>) -> Self { + Self { span, block: None } + } + + pub fn block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } +} + +impl<'a> StatefulWidget for Spinner<'a> { + type State = SpinnerState; + + fn render( + self, + area: ratatui::prelude::Rect, + buf: &mut ratatui::prelude::Buffer, + state: &mut Self::State, + ) { + let frame = MINIDOT_FRAMES + .get((state.frame) % MINIDOT_FRAMES.len()) + .expect("to find a valid static frame"); + + let line = Line::from(vec![Span::from(*frame), Span::from(" "), self.span]); + + let para = Paragraph::new(vec![line]); + let para = if let Some(block) = self.block { + para.block(block) + } else { + para + }; + + para.render(area, buf) + } +} + +pub struct SpinnerState { + last_event: Instant, + interval: Duration, + frame: usize, +} + +impl Default for SpinnerState { + fn default() -> Self { + Self { + last_event: Instant::now(), + interval: Duration::from_millis(1000 / 12), + frame: 0, + } + } +} + +const MINIDOT_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +impl SpinnerState { + pub fn update(&mut self, msg: &Msg) -> impl IntoCommand { + let mut batch = BatchCommand::default(); + + let now = Instant::now(); + if now.duration_since(self.last_event) >= self.interval { + self.last_event = now; + self.next_state(); + + batch.with(Command::new(|d| { + d.send(Msg::Tick); + + None + })); + } + + batch + } + + fn next_state(&mut self) { + self.frame = self.frame.wrapping_add(1); + } +} diff --git a/crates/gitnow/src/git_clone.rs b/crates/gitnow/src/git_clone.rs index 94a7ba3..aa4305e 100644 --- a/crates/gitnow/src/git_clone.rs +++ b/crates/gitnow/src/git_clone.rs @@ -1,5 +1,6 @@ -use crate::{app::App, git_provider::Repository}; +use crate::{app::App, components::inline_command::InlineCommand, git_provider::Repository}; +#[derive(Debug, Clone)] pub struct GitClone { app: &'static App, } diff --git a/crates/gitnow/src/main.rs b/crates/gitnow/src/main.rs index e703b0e..52f0bcc 100644 --- a/crates/gitnow/src/main.rs +++ b/crates/gitnow/src/main.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use anyhow::Context; use clap::{Parser, Subcommand}; use commands::root::RootCommand; +use components::inline_command::InlineCommand; use config::Config; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; @@ -13,6 +14,7 @@ mod app; mod cache; mod cache_codec; mod commands; +mod components; mod config; mod fuzzy_matcher; mod git_clone; @@ -56,7 +58,7 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( EnvFilter::builder() - .with_default_directive(LevelFilter::WARN.into()) + .with_default_directive(LevelFilter::ERROR.into()) .from_env_lossy(), ) .init();