diff --git a/Cargo.lock b/Cargo.lock index 905f747a30..1117ebe491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -802,6 +814,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1192,7 +1210,9 @@ dependencies = [ "filetreelist", "fuzzy-matcher", "gh-emoji", + "git2-testing", "indexmap", + "insta", "itertools 0.14.0", "log", "notify", @@ -2266,6 +2286,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "insta" +version = "1.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +dependencies = [ + "console", + "once_cell", + "regex", + "similar", +] + [[package]] name = "instability" version = "0.3.6" @@ -3463,6 +3495,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simplelog" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index fbcaad798f..4962404f13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } [dev-dependencies] env_logger = "0.11" +git2-testing = { path = "./git2-testing" } +insta = { version = "1.41.0", features = ["filters"] } pretty_assertions = "1.4" tempfile = "3" diff --git a/git2-testing/src/lib.rs b/git2-testing/src/lib.rs index 40e1679041..838e301367 100644 --- a/git2-testing/src/lib.rs +++ b/git2-testing/src/lib.rs @@ -20,13 +20,18 @@ pub fn repo_init_empty() -> (TempDir, Repository) { (td, repo) } -/// initialize test repo in temp path with an empty first commit -pub fn repo_init() -> (TempDir, Repository) { +/// initialize test repo in temp path with given suffix and an empty first commit +pub fn repo_init_suffix>( + suffix: Option, +) -> (TempDir, Repository) { init_log(); sandbox_config_files(); - let td = TempDir::new().unwrap(); + let td = match suffix { + Some(suffix) => TempDir::with_suffix(suffix).unwrap(), + None => TempDir::new().unwrap(), + }; let repo = Repository::init(td.path()).unwrap(); { let mut config = repo.config().unwrap(); @@ -45,6 +50,11 @@ pub fn repo_init() -> (TempDir, Repository) { (td, repo) } +/// initialize test repo in temp path with an empty first commit +pub fn repo_init() -> (TempDir, Repository) { + repo_init_suffix::<&std::ffi::OsStr>(None) +} + // init log fn init_log() { let _ = env_logger::builder() diff --git a/src/gitui.rs b/src/gitui.rs new file mode 100644 index 0000000000..a593614b3f --- /dev/null +++ b/src/gitui.rs @@ -0,0 +1,280 @@ +use std::time::Instant; + +use anyhow::Result; +use asyncgit::{sync::utils::repo_work_dir, AsyncGitNotification}; +use crossbeam_channel::{never, tick, unbounded, Receiver}; +use scopetime::scope_time; + +#[cfg(test)] +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::{ + app::{App, QuitState}, + args::CliArgs, + draw, + input::{Input, InputEvent, InputState}, + keys::KeyConfig, + select_event, + spinner::Spinner, + ui::style::Theme, + watcher::RepoWatcher, + AsyncAppNotification, AsyncNotification, QueueEvent, Updater, + SPINNER_INTERVAL, TICK_INTERVAL, +}; + +pub struct Gitui { + app: crate::app::App, + rx_input: Receiver, + rx_git: Receiver, + rx_app: Receiver, + rx_ticker: Receiver, + rx_watcher: Receiver<()>, +} + +impl Gitui { + pub(crate) fn new( + cliargs: CliArgs, + theme: Theme, + key_config: &KeyConfig, + updater: Updater, + ) -> Result { + let (tx_git, rx_git) = unbounded(); + let (tx_app, rx_app) = unbounded(); + + let input = Input::new(); + + let (rx_ticker, rx_watcher) = match updater { + Updater::NotifyWatcher => { + let repo_watcher = RepoWatcher::new( + repo_work_dir(&cliargs.repo_path)?.as_str(), + ); + + (never(), repo_watcher.receiver()) + } + Updater::Ticker => (tick(TICK_INTERVAL), never()), + }; + + let app = App::new( + cliargs, + tx_git, + tx_app, + input.clone(), + theme, + key_config.clone(), + )?; + + Ok(Self { + app, + rx_input: input.receiver(), + rx_git, + rx_app, + rx_ticker, + rx_watcher, + }) + } + + pub(crate) fn run_main_loop( + &mut self, + terminal: &mut ratatui::Terminal, + ) -> Result { + let spinner_ticker = tick(SPINNER_INTERVAL); + let mut spinner = Spinner::default(); + + self.app.update()?; + + loop { + let event = select_event( + &self.rx_input, + &self.rx_git, + &self.rx_app, + &self.rx_ticker, + &self.rx_watcher, + &spinner_ticker, + )?; + + { + if matches!(event, QueueEvent::SpinnerUpdate) { + spinner.update(); + spinner.draw(terminal)?; + continue; + } + + scope_time!("loop"); + + match event { + QueueEvent::InputEvent(ev) => { + if matches!( + ev, + InputEvent::State(InputState::Polling) + ) { + //Note: external ed closed, we need to re-hide cursor + terminal.hide_cursor()?; + } + self.app.event(ev)?; + } + QueueEvent::Tick | QueueEvent::Notify => { + self.app.update()?; + } + QueueEvent::AsyncEvent(ev) => { + if !matches!( + ev, + AsyncNotification::Git( + AsyncGitNotification::FinishUnchanged + ) + ) { + self.app.update_async(ev)?; + } + } + QueueEvent::SpinnerUpdate => unreachable!(), + } + + self.draw(terminal)?; + + spinner.set_state(self.app.any_work_pending()); + spinner.draw(terminal)?; + + if self.app.is_quit() { + break; + } + } + } + + Ok(self.app.quit_state()) + } + + fn draw( + &self, + terminal: &mut ratatui::Terminal, + ) -> std::io::Result<()> { + draw(terminal, &self.app) + } + + #[cfg(test)] + fn update_async(&mut self, event: crate::AsyncNotification) { + self.app.update_async(event).unwrap(); + } + + #[cfg(test)] + fn input_event( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) { + let event = crossterm::event::KeyEvent::new(code, modifiers); + self.app + .event(crate::input::InputEvent::Input( + crossterm::event::Event::Key(event), + )) + .unwrap(); + } + + #[cfg(test)] + fn wait_for_async_git_notification( + &self, + expected: AsyncGitNotification, + ) { + loop { + let actual = self + .rx_git + .recv_timeout(std::time::Duration::from_millis(100)) + .unwrap(); + + if actual == expected { + break; + } + } + } + + #[cfg(test)] + fn update(&mut self) { + self.app.update().unwrap(); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use asyncgit::{sync::RepoPath, AsyncGitNotification}; + use crossterm::event::{KeyCode, KeyModifiers}; + use git2_testing::repo_init_suffix; + use insta::assert_snapshot; + use ratatui::{backend::TestBackend, Terminal}; + + use crate::{ + args::CliArgs, gitui::Gitui, keys::KeyConfig, + ui::style::Theme, AsyncNotification, Updater, + }; + + // Macro adapted from: https://insta.rs/docs/cmd/ + macro_rules! apply_common_filters { + {} => { + let mut settings = insta::Settings::clone_current(); + // Windows and MacOS + // We don't match on the full path, but on the suffix we pass to `repo_init_suffix` below. + settings.add_filter(r" *\[…\]\S+-insta/?", "[TEMP_FILE]"); + // Linux Temp Folder + settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]"); + // Commit ids that follow a vertical bar + settings.add_filter(r"│[a-z0-9]{7} ", "│[AAAAA] "); + let _bound = settings.bind_to_scope(); + } + } + + #[test] + fn gitui_starts() { + apply_common_filters!(); + + let (temp_dir, _repo) = repo_init_suffix(Some("-insta")); + let path: RepoPath = temp_dir.path().to_str().unwrap().into(); + let cliargs = CliArgs { + theme: PathBuf::from("theme.ron"), + select_file: None, + repo_path: path, + notify_watcher: false, + key_bindings_path: None, + key_symbols_path: None, + }; + + let theme = Theme::init(&PathBuf::new()); + let key_config = KeyConfig::default(); + + let mut gitui = + Gitui::new(cliargs, theme, &key_config, Updater::Ticker) + .unwrap(); + + let mut terminal = + Terminal::new(TestBackend::new(90, 12)).unwrap(); + + gitui.draw(&mut terminal).unwrap(); + + assert_snapshot!("app_loading", terminal.backend()); + + let event = + AsyncNotification::Git(AsyncGitNotification::Status); + gitui.update_async(event); + + gitui.draw(&mut terminal).unwrap(); + + assert_snapshot!("app_loading_finished", terminal.backend()); + + gitui.input_event(KeyCode::Char('2'), KeyModifiers::empty()); + gitui.input_event( + key_config.keys.tab_log.code, + key_config.keys.tab_log.modifiers, + ); + + gitui.wait_for_async_git_notification( + AsyncGitNotification::Log, + ); + + gitui.update(); + + gitui.draw(&mut terminal).unwrap(); + + assert_snapshot!( + "app_log_tab_showing_one_commit", + terminal.backend() + ); + } +} diff --git a/src/main.rs b/src/main.rs index 12fbb71ddd..2159a1855e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,7 @@ mod bug_report; mod clipboard; mod cmdbar; mod components; +mod gitui; mod input; mod keys; mod notify_mutex; @@ -85,12 +86,9 @@ use crate::{ }; use anyhow::{anyhow, bail, Result}; use app::QuitState; -use asyncgit::{ - sync::{utils::repo_work_dir, RepoPath}, - AsyncGitNotification, -}; +use asyncgit::{sync::RepoPath, AsyncGitNotification}; use backtrace::Backtrace; -use crossbeam_channel::{never, tick, unbounded, Receiver, Select}; +use crossbeam_channel::{Receiver, Select}; use crossterm::{ terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, @@ -98,12 +96,11 @@ use crossterm::{ }, ExecutableCommand, }; -use input::{Input, InputEvent, InputState}; +use gitui::Gitui; +use input::InputEvent; use keys::KeyConfig; use ratatui::backend::CrosstermBackend; use scopeguard::defer; -use scopetime::scope_time; -use spinner::Spinner; use std::{ io::{self, Stdout}, panic, @@ -111,7 +108,6 @@ use std::{ time::{Duration, Instant}, }; use ui::style::Theme; -use watcher::RepoWatcher; type Terminal = ratatui::Terminal>; @@ -187,7 +183,6 @@ fn main() -> Result<()> { let mut terminal = start_terminal(io::stdout(), &cliargs.repo_path)?; - let input = Input::new(); let updater = if cliargs.notify_watcher { Updater::NotifyWatcher @@ -202,8 +197,7 @@ fn main() -> Result<()> { app_start, args.clone(), theme.clone(), - key_config.clone(), - &input, + &key_config, updater, &mut terminal, )?; @@ -230,106 +224,15 @@ fn run_app( app_start: Instant, cliargs: CliArgs, theme: Theme, - key_config: KeyConfig, - input: &Input, + key_config: &KeyConfig, updater: Updater, terminal: &mut Terminal, ) -> Result { - let (tx_git, rx_git) = unbounded(); - let (tx_app, rx_app) = unbounded(); - - let rx_input = input.receiver(); - - let (rx_ticker, rx_watcher) = match updater { - Updater::NotifyWatcher => { - let repo_watcher = RepoWatcher::new( - repo_work_dir(&cliargs.repo_path)?.as_str(), - ); - - (never(), repo_watcher.receiver()) - } - Updater::Ticker => (tick(TICK_INTERVAL), never()), - }; - - let spinner_ticker = tick(SPINNER_INTERVAL); - - let mut app = App::new( - cliargs, - tx_git, - tx_app, - input.clone(), - theme, - key_config, - )?; - - let mut spinner = Spinner::default(); - let mut first_update = true; + let mut gitui = Gitui::new(cliargs, theme, key_config, updater)?; log::trace!("app start: {} ms", app_start.elapsed().as_millis()); - loop { - let event = if first_update { - first_update = false; - QueueEvent::Notify - } else { - select_event( - &rx_input, - &rx_git, - &rx_app, - &rx_ticker, - &rx_watcher, - &spinner_ticker, - )? - }; - - { - if matches!(event, QueueEvent::SpinnerUpdate) { - spinner.update(); - spinner.draw(terminal)?; - continue; - } - - scope_time!("loop"); - - match event { - QueueEvent::InputEvent(ev) => { - if matches!( - ev, - InputEvent::State(InputState::Polling) - ) { - //Note: external ed closed, we need to re-hide cursor - terminal.hide_cursor()?; - } - app.event(ev)?; - } - QueueEvent::Tick | QueueEvent::Notify => { - app.update()?; - } - QueueEvent::AsyncEvent(ev) => { - if !matches!( - ev, - AsyncNotification::Git( - AsyncGitNotification::FinishUnchanged - ) - ) { - app.update_async(ev)?; - } - } - QueueEvent::SpinnerUpdate => unreachable!(), - } - - draw(terminal, &app)?; - - spinner.set_state(app.any_work_pending()); - spinner.draw(terminal)?; - - if app.is_quit() { - break; - } - } - } - - Ok(app.quit_state()) + gitui.run_main_loop(terminal) } fn setup_terminal() -> Result<()> { @@ -353,7 +256,10 @@ fn shutdown_terminal() { } } -fn draw(terminal: &mut Terminal, app: &App) -> io::Result<()> { +fn draw( + terminal: &mut ratatui::Terminal, + app: &App, +) -> io::Result<()> { if app.requires_redraw() { terminal.clear()?; } diff --git a/src/snapshots/gitui__gitui__tests__app_loading.snap b/src/snapshots/gitui__gitui__tests__app_loading.snap new file mode 100644 index 0000000000..6a8025c3fb --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__app_loading.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +expression: terminal.backend() +snapshot_kind: text +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Unstaged Changes───────────────────────────┐┌Diff: ─────────────────────────────────────┐" +"│Loading ... ││ │" +"│ ││ │" +"│ ││ │" +"└───────────────────────────────────{master}┘│ │" +"┌Staged Changes─────────────────────────────┐│ │" +"│Loading ... ││ │" +"│ ││ │" +"└───────────────────────────────────────────┘└───────────────────────────────────────────┘" +" " diff --git a/src/snapshots/gitui__gitui__tests__app_loading_finished.snap b/src/snapshots/gitui__gitui__tests__app_loading_finished.snap new file mode 100644 index 0000000000..9722900170 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__app_loading_finished.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +expression: terminal.backend() +snapshot_kind: text +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Unstaged Changes───────────────────────────┐┌Diff: ─────────────────────────────────────┐" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"└───────────────────────────────────{master}┘│ │" +"┌Staged Changes─────────────────────────────┐│ │" +"│ ││ │" +"│ ││ │" +"└───────────────────────────────────────────┘└───────────────────────────────────────────┘" +"Branches [b] Push [p] Fetch [⇧F] Pull [f] Undo Commit [⇧U] Submodules [⇧S] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap new file mode 100644 index 0000000000..bbdd5be8a4 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +expression: terminal.backend() +snapshot_kind: text +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 1/1──────────────────────────────────────────────────────────────────────────────┐" +"│[AAAAA] <1m ago name initial █" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Details [⏎] Branches [b] Compare [⇧C] Copy Hash [y] Tag [t] more [.]" diff --git a/src/spinner.rs b/src/spinner.rs index 2fc6b3a2cb..c8066ae69e 100644 --- a/src/spinner.rs +++ b/src/spinner.rs @@ -1,7 +1,4 @@ -use ratatui::{ - backend::{Backend, CrosstermBackend}, - Terminal, -}; +use ratatui::{backend::Backend, Terminal}; use std::{cell::Cell, char, io}; // static SPINNER_CHARS: &[char] = &['◢', '◣', '◤', '◥']; @@ -39,9 +36,9 @@ impl Spinner { } /// draws or removes spinner char depending on `pending` state - pub fn draw( + pub fn draw( &self, - terminal: &mut Terminal>, + terminal: &mut Terminal, ) -> io::Result<()> { let idx = self.idx;